Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 63 additions & 16 deletions src/components/AlertsPanel.jsx
Original file line number Diff line number Diff line change
@@ -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.",
);
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
Expand All @@ -135,11 +156,37 @@ export default function AlertsPanel({

return (
<section data-testid="alerts-panel" className="panel">
<div className="panel-head">
<h2>Alerts &amp; Notifications</h2>
<p>Health warnings based on safe pollutant thresholds</p>
<div
className="panel-head"
style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}
>
<div>
<h2>Alerts &amp; Notifications</h2>
<p>Health warnings based on safe pollutant thresholds</p>
</div>
<button
type="button"
onClick={() => setShowSettings((prev) => !prev)}
aria-label="Open notification settings"
data-testid="notification-settings-toggle"
className="notif-gear-btn"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>

</div>

{showSettings && (
<NotificationSettings
settings={notificationSettings}
onUpdate={updateSettings}
onClose={() => setShowSettings(false)}
/>
)}

{permission === "default" && (
<div
style={{
Expand Down Expand Up @@ -205,7 +252,7 @@ export default function AlertsPanel({
}}
>
Receive a browser notification when AQI exceeds{" "}
{HAZARDOUS_AQI_THRESHOLD} (hazardous level)
{notificationSettings.aqiThreshold}
</span>
</div>

Expand Down
136 changes: 136 additions & 0 deletions src/components/NotificationSettings.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
data-testid="notification-settings-panel"
className="notif-settings-panel"
>
{/* Header: title + close */}
<div className="notif-settings-header">
<span className="notif-settings-title">Notification Settings</span>
<button
type="button"
onClick={onClose}
aria-label="Close notification settings"
className="notif-settings-close"
>
</button>
</div>

{/* AQI threshold */}
<div className="notif-settings-section">
<span className="notif-section-label">AQI Threshold</span>
<label className="notif-aqi-row">
Alert me when AQI &gt;
<input
type="number"
data-testid="aqi-threshold-input"
value={aqiThreshold}
onChange={(e) => setAqiThreshold(e.target.value)}
className="notif-input"
/>
</label>
</div>

{/* Per-pollutant thresholds */}
<div className="notif-settings-section">
<span className="notif-section-label">Per-Pollutant Thresholds</span>
<div className="notif-pollutant-rows">
{POLLUTANT_FIELDS.map(({ key, label }) => (
<label
key={key}
className="notif-pollutant-row"
>
<span className="notif-pollutant-name">{label}</span>
<input
type="number"
data-testid={`pollutant-threshold-${key}`}
value={pollutantThresholds[key]}
onChange={(e) =>
setPollutantThresholds((prev) => ({ ...prev, [key]: e.target.value }))
}
className="notif-input"
/>
</label>
))}
</div>
</div>

{/* Quiet Hours */}
<div className="notif-settings-section">
<span className="notif-section-label">Quiet Hours</span>
<label className="notif-quiet-label">
<input
type="checkbox"
data-testid="quiet-hours-enabled"
checked={quietHoursEnabled}
onChange={(e) => setQuietHoursEnabled(e.target.checked)}
/>
Enable quiet hours
</label>
{quietHoursEnabled && (
<div className="notif-quiet-times">
<label className="notif-time-label">
From
<input
type="time"
data-testid="quiet-hours-start"
value={quietStart}
onChange={(e) => setQuietStart(e.target.value)}
className="notif-time-input"
/>
</label>
<label className="notif-time-label">
To
<input
type="time"
data-testid="quiet-hours-end"
value={quietEnd}
onChange={(e) => setQuietEnd(e.target.value)}
className="notif-time-input"
/>
</label>
</div>
)}
</div>

{/* Save */}
<button
type="button"
data-testid="save-notification-settings"
onClick={handleSave}
className="notif-save-btn"
>
Save Settings
</button>
</div>
);
}
93 changes: 93 additions & 0 deletions src/hooks/useNotificationSettings.js
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading