diff --git a/src/App.jsx b/src/App.jsx index 2bafff2..53d2df4 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -65,6 +65,10 @@ import SmartAlertsDashboard from "./components/SmartAlertsDashboard"; import OceanAcidificationMonitor from "./components/OceanAcidificationMonitor"; import HealthImpactDashboard from "./components/HealthImpactDashboard"; +import DataExportDashboard from "./components/DataExportDashboard"; +import CityComparisonReport from "./components/CityComparisonReport"; +import ExposureTimelineTracker from "./components/ExposureTimelineTracker"; +import AlertRulesEngine from "./components/AlertRulesEngine"; const AqiMissionGame = lazy(() => import("./components/AqiMissionGame")); const HotspotScoutGame = lazy(() => import("./components/HotspotScoutGame")); @@ -360,6 +364,10 @@ export function SectionNav({ activeSection, onSectionChange }) { { id: "smart-alerts", label: "Smart Alerts" }, { id: "ocean-acid", label: "Ocean Acidification" }, { id: "health-impact", label: "Health Impact" }, + { id: "data-export", label: "Data Export" }, + { id: "city-comparison-report", label: "City Compare Report" }, + { id: "exposure-timeline", label: "Exposure Timeline" }, + { id: "alert-rules", label: "Alert Rules" }, ]; const [isMenuOpen, setIsMenuOpen] = useState(false); const menuRef = useRef(null); @@ -1280,6 +1288,69 @@ function AppContent() { {activeSection === "ocean-acid" && } {activeSection === "health-impact" && } + {activeSection === "data-export" && ( +
+ +
+ )} + {activeSection === "city-comparison-report" && ( +
+ +
+ )} + {activeSection === "exposure-timeline" && ( +
+ +
+ )} + {activeSection === "alert-rules" && ( +
+ +
+ )} {activeSection === "CarbonCalculator" && (
+ + {value} + {label} +
+ ); +}); + +const RuleCard = memo(function RuleCard({ rule, onToggle, onDelete, onEdit }) { + const pollutant = POLLUTANT_OPTIONS.find((p) => p.key === rule.pollutant); + const operator = OPERATORS.find((o) => o.key === rule.operator); + const timeWindow = TIME_WINDOWS.find((tw) => tw.key === rule.timeWindow); + const severityCls = rule.severity === "critical" ? styles.severityCritical + : rule.severity === "warning" ? styles.severityWarning + : styles.severityInfo; + const cardCls = rule.enabled ? styles.ruleEnabled : styles.ruleDisabled; + const sevBorder = rule.severity === "critical" ? styles.ruleSeverityCritical + : rule.severity === "warning" ? styles.ruleSeverityWarning + : styles.ruleSeverityInfo; + + return ( +
+ + +
+ + ); +}); + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function AlertRulesEngine({ current }) { + const { t } = useTranslation(); + const [rules, setRules] = useState(() => readRules()); + const [showForm, setShowForm] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [toastMessage, setToastMessage] = useState(""); + const [notifPermission, setNotifPermission] = useState("unknown"); + + // Form state + const [formName, setFormName] = useState(""); + const [formPollutant, setFormPollutant] = useState("us_aqi"); + const [formOperator, setFormOperator] = useState("above"); + const [formThreshold, setFormThreshold] = useState("100"); + const [formTimeWindow, setFormTimeWindow] = useState("any"); + const [formThrottle, setFormThrottle] = useState(6); + const [formSeverity, setFormSeverity] = useState("warning"); + + // Evaluate rules on current data + const triggeredCount = useMemo(() => { + if (!current) return 0; + const { triggered } = evaluateRules(current); + // Send notifications for triggered rules + for (const rule of triggered) { + sendNotification(rule, current); + } + return triggered.length; + }, [current]); + + // Check notification permission on mount + useEffect(() => { + setNotifPermission(requestNotificationPermission()); + }, []); + + const showToast = useCallback((msg) => { + setToastMessage(msg); + setTimeout(() => setToastMessage(""), 2500); + }, []); + + const refreshRules = useCallback(() => { + setRules(readRules()); + }, []); + + // Stats + const activeRules = useMemo(() => rules.filter((r) => r.enabled), [rules]); + const totalRules = rules.length; + + // Form handlers + const resetForm = useCallback(() => { + setFormName(""); + setFormPollutant("us_aqi"); + setFormOperator("above"); + setFormThreshold("100"); + setFormTimeWindow("any"); + setFormThrottle(6); + setFormSeverity("warning"); + setEditingRule(null); + setShowForm(false); + }, []); + + const handleEdit = useCallback((rule) => { + setEditingRule(rule); + setFormName(rule.name); + setFormPollutant(rule.pollutant); + setFormOperator(rule.operator); + setFormThreshold(String(rule.threshold)); + setFormTimeWindow(rule.timeWindow); + setFormThrottle(rule.throttleHours); + setFormSeverity(rule.severity); + setShowForm(true); + }, []); + + const handleSubmit = useCallback((e) => { + e.preventDefault(); + const threshold = parseFloat(formThreshold); + if (!Number.isFinite(threshold)) { + showToast(t("alerts.invalidThreshold", "Please enter a valid threshold")); + return; + } + + const data = { + name: formName || "Alert Rule", + pollutant: formPollutant, + operator: formOperator, + threshold, + timeWindow: formTimeWindow, + throttleHours: formThrottle, + severity: formSeverity, + }; + + if (editingRule) { + updateRule(editingRule.id, data); + showToast(t("alerts.updated", "Rule updated!")); + } else { + createRule(data); + showToast(t("alerts.created", "Rule created!")); + } + + refreshRules(); + resetForm(); + }, [formName, formPollutant, formOperator, formThreshold, formTimeWindow, formThrottle, formSeverity, editingRule, t, showToast, refreshRules, resetForm]); + + const handleDelete = useCallback((id) => { + if (typeof window !== "undefined" && window.confirm("Delete this alert rule?")) { + deleteRule(id); + refreshRules(); + showToast(t("alerts.deleted", "Rule deleted")); + } + }, [refreshRules, t, showToast]); + + const handleToggle = useCallback((id) => { + toggleRule(id); + refreshRules(); + }, [refreshRules]); + + const handleAddPreset = useCallback((preset) => { + createRule(preset); + refreshRules(); + showToast(t("alerts.presetAdded", "Preset rule added!")); + }, [refreshRules, t, showToast]); + + const handleEnableNotifications = useCallback(async () => { + const result = await requestNotificationPermission(); + setNotifPermission(result); + if (result === "granted") { + showToast(t("alerts.notifsEnabled", "Notifications enabled!")); + } else { + showToast(t("alerts.notifsDenied", "Notifications blocked by browser")); + } + }, [t, showToast]); + + return ( +
+
+ {/* Header */} +
+

๐Ÿ”” {t("alerts.title", "Alert Rules Engine")}

+

{t("alerts.subtitle", "Create custom alert rules based on AQI thresholds and conditions")}

+
+ + {/* Stats */} +
+ + + + +
+ + {/* Notification permission */} + {notifPermission !== "granted" && ( +
+ +
+ )} + + {/* Create / Edit form */} +
+
+

๐Ÿ“ {editingRule ? t("alerts.editRule", "Edit Rule") : t("alerts.createRule", "Create Rule")}

+ +
+ + {showForm && ( +
+
+ + setFormName(e.target.value)} + placeholder={t("alerts.namePlaceholder", "e.g. High PM2.5 Alert")} + /> +
+
+ + +
+
+ + +
+
+ + setFormThreshold(e.target.value)} + min="0" + step="1" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ )} +
+ + {/* Active Rules */} +
+

๐Ÿ“‹ {t("alerts.yourRules", "Your Rules")} ({rules.length})

+ {rules.length === 0 ? ( +
+

{t("alerts.noRules", "No rules yet. Create one above or add a preset below.")}

+
+ ) : ( +
+ {rules.map((rule) => ( + + ))} +
+ )} +
+ + {/* Preset Rules */} +
+

โšก {t("alerts.presetRules", "Quick Preset Rules")}

+
+ {PRESET_RULES.map((preset, idx) => ( + + ))} +
+
+ + {/* Toast */} +
+ {toastMessage} +
+
+
+ ); +} diff --git a/src/components/AlertRulesEngine.module.css b/src/components/AlertRulesEngine.module.css new file mode 100644 index 0000000..0bd5839 --- /dev/null +++ b/src/components/AlertRulesEngine.module.css @@ -0,0 +1,221 @@ +.root { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1.5rem; + max-width: 1100px; + margin: 0 auto; +} + +.header { text-align: center; } +.headerTitle { font-size: 1.6rem; font-weight: 700; margin: 0 0 0.25rem; color: var(--text-primary, #0f172a); } +.headerSubtitle { font-size: 0.95rem; color: var(--text-secondary, #64748b); margin: 0; } + +/* Stats row */ +.statsRow { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; +} + +.statCard { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.85rem 0.5rem; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.65rem; + text-align: center; +} + +.statIcon { font-size: 1.3rem; margin-bottom: 0.2rem; } +.statValue { font-size: 1.25rem; font-weight: 700; color: var(--text-primary, #0f172a); } +.statLabel { font-size: 0.7rem; color: var(--text-secondary, #64748b); margin-top: 0.15rem; } + +/* Section card */ +.section { + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.75rem; + padding: 1.25rem; +} + +.sectionTitle { + font-size: 1.05rem; + font-weight: 600; + margin: 0 0 0.75rem; + color: var(--text-primary, #0f172a); + display: flex; + align-items: center; + gap: 0.45rem; +} + +/* Create form */ +.form { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.75rem; + padding: 1rem; + background: var(--bg-secondary, #f8fafc); + border-radius: 0.5rem; + border: 1px dashed var(--border-color, #cbd5e1); +} + +@media (max-width: 700px) { + .form { grid-template-columns: 1fr; } +} + +.formGroup { display: flex; flex-direction: column; gap: 0.25rem; } +.formLabel { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary, #475569); } + +.formInput, .formSelect { + padding: 0.45rem 0.65rem; + border: 1px solid var(--border-color, #cbd5e1); + border-radius: 0.4rem; + font-size: 0.82rem; + background: var(--bg-card, #fff); + color: var(--text-primary, #0f172a); +} + +.formInput:focus, .formSelect:focus { outline: 2px solid var(--brand, #0d9488); outline-offset: 1px; } + +.formFullWidth { grid-column: 1 / -1; } + +.formActions { + grid-column: 1 / -1; + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.btn { + padding: 0.5rem 1rem; + border: 1px solid var(--border-color, #cbd5e1); + border-radius: 0.4rem; + background: var(--bg-card, #fff); + color: var(--text-primary, #0f172a); + font-weight: 600; + font-size: 0.82rem; + cursor: pointer; + transition: background 0.12s; +} + +.btn:hover { background: var(--bg-secondary, #f8fafc); } +.btnPrimary { background: var(--brand, #0d9488); color: #fff; border-color: var(--brand, #0d9488); } +.btnPrimary:hover { background: #0b8577; } +.btnDanger { color: #ef4444; border-color: #ef4444; } +.btnDanger:hover { background: #fef2f2; } +.btnSmall { padding: 0.3rem 0.6rem; font-size: 0.75rem; } + +/* Rules list */ +.rulesList { display: flex; flex-direction: column; gap: 0.5rem; } + +.ruleCard { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1rem; + background: var(--bg-secondary, #f8fafc); + border-radius: 0.5rem; + border-left: 4px solid transparent; + transition: transform 0.1s; +} + +.ruleCard:hover { transform: translateX(2px); } +.ruleEnabled { opacity: 1; } +.ruleDisabled { opacity: 0.5; } + +.ruleSeverityCritical { border-left-color: #ef4444; } +.ruleSeverityWarning { border-left-color: #f59e0b; } +.ruleSeverityInfo { border-left-color: #3b82f6; } + +.ruleToggle { + width: 40px; + height: 22px; + border-radius: 11px; + border: none; + cursor: pointer; + position: relative; + transition: background 0.2s; + background: #cbd5e1; + flex-shrink: 0; +} + +.ruleToggleOn { background: var(--brand, #0d9488); } + +.ruleToggle::after { + content: ''; + position: absolute; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + top: 2px; + left: 2px; + transition: transform 0.2s; +} + +.ruleToggleOn::after { transform: translateX(18px); } + +.ruleContent { min-width: 0; } +.ruleName { font-size: 0.9rem; font-weight: 600; color: var(--text-primary, #0f172a); margin: 0; } +.ruleCondition { font-size: 0.75rem; color: var(--text-secondary, #64748b); margin: 0.2rem 0 0; } +.ruleMeta { font-size: 0.68rem; color: var(--text-secondary, #94a3b8); margin: 0.15rem 0 0; } + +.ruleActions { display: flex; gap: 0.35rem; flex-shrink: 0; } + +/* Severity badge */ +.severityBadge { + font-size: 0.65rem; + font-weight: 600; + padding: 0.1rem 0.4rem; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.severityCritical { background: #fef2f2; color: #ef4444; } +.severityWarning { background: #fffbeb; color: #f59e0b; } +.severityInfo { background: #eff6ff; color: #3b82f6; } + +/* Preset grid */ +.presetGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; +} + +.presetCard { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.85rem; + background: var(--bg-secondary, #f8fafc); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.5rem; + cursor: pointer; + transition: border-color 0.12s, box-shadow 0.12s; +} + +.presetCard:hover { border-color: var(--brand, #0d9488); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); } + +.presetName { font-size: 0.85rem; font-weight: 600; color: var(--text-primary, #0f172a); margin: 0; } +.presetDesc { font-size: 0.75rem; color: var(--text-secondary, #64748b); margin: 0; } + +/* Toast */ +.toast { + position: fixed; bottom: 1.5rem; left: 50%; + transform: translateX(-50%) translateY(120%); + background: #1e293b; color: #fff; + padding: 0.65rem 1.25rem; border-radius: 0.5rem; + font-size: 0.85rem; font-weight: 500; z-index: 9999; + opacity: 0; transition: transform 0.25s ease, opacity 0.25s ease; + pointer-events: none; +} +.toastVisible { transform: translateX(-50%) translateY(0); opacity: 1; } + +/* Empty state */ +.emptyState { text-align: center; padding: 2rem 1rem; color: var(--text-secondary, #94a3b8); } +.emptyText { font-size: 0.9rem; margin: 0; } diff --git a/src/components/AlertRulesEngine.test.jsx b/src/components/AlertRulesEngine.test.jsx new file mode 100644 index 0000000..9467d58 --- /dev/null +++ b/src/components/AlertRulesEngine.test.jsx @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import AlertRulesEngine from "./AlertRulesEngine"; +import { + readRules, + writeRules, + createRule, + updateRule, + deleteRule, + toggleRule, + evaluateRules, + POLLUTANT_OPTIONS, + OPERATORS, + TIME_WINDOWS, + THROTTLE_OPTIONS, + PRESET_RULES, +} from "../services/alertRulesService"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key, opts) => (typeof opts === "string" ? opts : opts?.defaultValue || key), + }), +})); + +// --------------------------------------------------------------------------- +// Service tests +// --------------------------------------------------------------------------- +describe("alertRulesService", () => { + beforeEach(() => { + // Clear localStorage + try { localStorage.clear(); } catch { /* ignore */ } + }); + + describe("CRUD operations", () => { + it("creates a rule with generated ID", () => { + const rule = createRule({ name: "Test Rule", threshold: 120 }); + expect(rule.id).toMatch(/^rule_/); + expect(rule.name).toBe("Test Rule"); + expect(rule.threshold).toBe(120); + expect(rule.enabled).toBe(true); + }); + + it("persists and reads rules", () => { + createRule({ name: "Rule 1" }); + createRule({ name: "Rule 2" }); + const rules = readRules(); + expect(rules.length).toBe(2); + expect(rules[0].name).toBe("Rule 1"); + expect(rules[1].name).toBe("Rule 2"); + }); + + it("updates a rule by ID", () => { + const rule = createRule({ name: "Original" }); + const updated = updateRule(rule.id, { name: "Updated", threshold: 200 }); + expect(updated.name).toBe("Updated"); + expect(updated.threshold).toBe(200); + expect(readRules().find((r) => r.id === rule.id).name).toBe("Updated"); + }); + + it("returns null when updating non-existent rule", () => { + expect(updateRule("nonexistent", { name: "X" })).toBeNull(); + }); + + it("deletes a rule by ID", () => { + const rule = createRule({ name: "Delete Me" }); + expect(deleteRule(rule.id)).toBe(true); + expect(readRules().find((r) => r.id === rule.id)).toBeUndefined(); + }); + + it("returns false when deleting non-existent rule", () => { + expect(deleteRule("nonexistent")).toBe(false); + }); + + it("toggles rule enabled state", () => { + const rule = createRule({ name: "Toggle Me", enabled: true }); + const toggled = toggleRule(rule.id); + expect(toggled.enabled).toBe(false); + const toggledAgain = toggleRule(rule.id); + expect(toggledAgain.enabled).toBe(true); + }); + }); + + describe("evaluateRules", () => { + it("triggers rule when threshold is met", () => { + createRule({ name: "High AQI", pollutant: "us_aqi", operator: "above", threshold: 100, timeWindow: "any", throttleHours: 0, enabled: true }); + const current = { us_aqi: 150, pm2_5: 40, pm10: 60, nitrogen_dioxide: 25, ozone: 35, carbon_monoxide: 0.8 }; + const { triggered } = evaluateRules(current); + expect(triggered.length).toBe(1); + expect(triggered[0].name).toBe("High AQI"); + }); + + it("does not trigger when threshold not met", () => { + createRule({ name: "High AQI", pollutant: "us_aqi", operator: "above", threshold: 200, timeWindow: "any", throttleHours: 0, enabled: true }); + const current = { us_aqi: 150 }; + const { triggered } = evaluateRules(current); + expect(triggered.length).toBe(0); + }); + + it("does not trigger disabled rules", () => { + createRule({ name: "Disabled", pollutant: "us_aqi", operator: "above", threshold: 50, timeWindow: "any", throttleHours: 0, enabled: false }); + const current = { us_aqi: 150 }; + const { triggered } = evaluateRules(current); + expect(triggered.length).toBe(0); + }); + + it("handles below operator", () => { + createRule({ name: "Low AQI", pollutant: "us_aqi", operator: "below", threshold: 50, timeWindow: "any", throttleHours: 0, enabled: true }); + const current = { us_aqi: 30 }; + const { triggered } = evaluateRules(current); + expect(triggered.length).toBe(1); + }); + + it("returns empty for null current", () => { + const { triggered } = evaluateRules(null); + expect(triggered).toEqual([]); + }); + }); + + describe("constants", () => { + it("exports all expected arrays", () => { + expect(POLLUTANT_OPTIONS.length).toBeGreaterThan(0); + expect(OPERATORS.length).toBe(3); + expect(TIME_WINDOWS.length).toBeGreaterThan(0); + expect(THROTTLE_OPTIONS.length).toBeGreaterThan(0); + expect(PRESET_RULES.length).toBeGreaterThan(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Component tests +// --------------------------------------------------------------------------- +describe("AlertRulesEngine", () => { + it("renders the panel with title", () => { + render(); + expect(screen.getByTestId("alert-rules-engine")).toBeTruthy(); + expect(screen.getByText(/Alert Rules Engine/)).toBeTruthy(); + }); + + it("displays stats row", () => { + render(); + expect(screen.getByTestId("stats-row")).toBeTruthy(); + }); + + it("displays rules list section", () => { + render(); + expect(screen.getByTestId("rules-list")).toBeTruthy(); + }); + + it("displays presets section", () => { + render(); + expect(screen.getByTestId("presets-section")).toBeTruthy(); + }); + + it("shows empty state when no rules", () => { + render(); + expect(screen.getByText(/No rules yet/)).toBeTruthy(); + }); + + it("shows form when New Rule is clicked", () => { + render(); + fireEvent.click(screen.getByText(/New Rule/)); + expect(screen.getByTestId("rule-form")).toBeTruthy(); + }); +}); diff --git a/src/components/CityComparisonReport.jsx b/src/components/CityComparisonReport.jsx new file mode 100644 index 0000000..3b74ba6 --- /dev/null +++ b/src/components/CityComparisonReport.jsx @@ -0,0 +1,343 @@ +import { useState, useMemo, useCallback, memo } from "react"; +import { useTranslation } from "react-i18next"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, + Cell, +} from "recharts"; +import { useSWR } from "../hooks/useSWR"; +import { fetchCityComparisons } from "../services/airQualityService"; +import { + rankCities, + getAQIBand, + categoriseByRisk, + comparePollutants, + comparisonToCSV, + generateComparisonSummary, +} from "../services/cityComparisonReportService"; +import { triggerDownload, copyToClipboard } from "../services/dataExportService"; +import styles from "./CityComparisonReport.module.css"; + +// --------------------------------------------------------------------------- +// Memoized sub-components +// --------------------------------------------------------------------------- + +const StatCard = memo(function StatCard({ icon, value, label, color }) { + return ( +
+ + {value} + {label} +
+ ); +}); + +const RankingItem = memo(function RankingItem({ city }) { + const rankEmoji = city.rank === 1 ? "๐Ÿฅ‡" : city.rank === 2 ? "๐Ÿฅˆ" : city.rank === 3 ? "๐Ÿฅ‰" : `#${city.rank}`; + return ( +
+ {rankEmoji} + {city.name} + + {city.aqi} + + + {city.band?.label} + +
+ ); +}); + +function ChartTooltip({ active, payload, label }) { + if (!active || !payload || payload.length === 0) return null; + return ( +
+
{label}
+ {payload.map((entry) => ( +
+ {entry.name}: {entry.value ?? "โ€”"} +
+ ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function CityComparisonReport({ savedLocations, cityName }) { + const { t } = useTranslation(); + const [toastMessage, setToastMessage] = useState(""); + + // Fetch AQI data for all saved cities + current city + const allCities = useMemo(() => { + const cities = [{ name: cityName, isCurrent: true }]; + for (const loc of savedLocations || []) { + if (loc.name !== cityName) { + cities.push({ name: loc.name, lat: loc.lat, lon: loc.lon }); + } + } + return cities; + }, [savedLocations, cityName]); + + // Fetch AQI for each saved city using SWR + const cityDataResults = useMemo(() => { + return allCities.map((city) => { + const key = city.lat && city.lon ? `aqi_${city.lat}_${city.lon}` : null; + return { ...city, swrKey: key }; + }); + }, [allCities]); + + // We use a single SWR call for comparisons + const { data: cityComparisons } = useSWR("city_comparisons", () => fetchCityComparisons()); + + // Merge fetched comparison data with saved locations + const mergedCities = useMemo(() => { + const result = []; + + for (const city of allCities) { + // Try to find in cityComparisons + const match = (cityComparisons || []).find( + (c) => c.name?.toLowerCase() === city.name.toLowerCase(), + ); + + if (match && !match.unavailable && match.aqi != null) { + result.push({ + name: city.name, + aqi: match.aqi, + pm2_5: match.pm2_5 ?? null, + pm10: match.pm10 ?? null, + no2: match.nitrogen_dioxide ?? null, + o3: match.ozone ?? null, + co: match.carbon_monoxide ?? null, + }); + } else { + result.push({ + name: city.name, + aqi: null, + pm2_5: null, + pm10: null, + no2: null, + o3: null, + co: null, + }); + } + } + + return result; + }, [allCities, cityComparisons]); + + // Ranked cities + const rankedCities = useMemo(() => rankCities(mergedCities), [mergedCities]); + + // Risk groups + const riskGroups = useMemo(() => categoriseByRisk(mergedCities), [mergedCities]); + + // Pollutant comparison + const pollutantData = useMemo(() => comparePollutants(mergedCities), [mergedCities]); + + // Chart data for AQI comparison + const chartData = useMemo(() => + mergedCities + .filter((c) => typeof c.aqi === "number") + .map((c) => ({ + name: c.name.length > 12 ? c.name.slice(0, 12) + "โ€ฆ" : c.name, + aqi: c.aqi, + fill: getAQIBand(c.aqi).color, + })), + [mergedCities], + ); + + // Stats + const validAqis = mergedCities.filter((c) => typeof c.aqi === "number").map((c) => c.aqi); + const bestCity = rankedCities[0]; + const worstCity = rankedCities.length > 0 ? rankedCities[rankedCities.length - 1] : null; + + const showToast = useCallback((msg) => { + setToastMessage(msg); + setTimeout(() => setToastMessage(""), 2500); + }, []); + + const handleExportCSV = useCallback(() => { + const csv = comparisonToCSV(rankedCities); + triggerDownload(csv, `city-comparison-${new Date().toISOString().slice(0, 10)}.csv`, "text/csv"); + showToast(t("comparison.downloaded", "CSV downloaded!")); + }, [rankedCities, t, showToast]); + + const handleCopyReport = useCallback(async () => { + const report = generateComparisonSummary(rankedCities); + const ok = await copyToClipboard(report); + showToast(ok ? t("comparison.copied", "Report copied!") : t("comparison.copyFailed", "Copy failed")); + }, [rankedCities, t, showToast]); + + // --- Empty state --- + if (!savedLocations || savedLocations.length === 0) { + return ( +
+
+
+

๐Ÿ™๏ธ {t("comparison.title", "City Comparison Report")}

+

{t("comparison.subtitle", "Compare air quality across your saved cities")}

+
+
+
๐Ÿ“
+

{t("comparison.noCities", "No saved cities yet")}

+

{t("comparison.noCitiesDesc", "Save locations from the dashboard controls to compare their air quality side by side.")}

+
+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+

๐Ÿ™๏ธ {t("comparison.title", "City Comparison Report")}

+

+ {t("comparison.subtitleCount", "Comparing air quality across {{count}} cities", { count: mergedCities.length })} +

+
+ + {/* Stats */} +
+ + + + {bestCity && worstCity && bestCity.aqi != null && worstCity.aqi != null && ( + + )} +
+ + {/* AQI Chart */} + {chartData.length > 1 && ( +
+

๐Ÿ“Š {t("comparison.aqiChart", "AQI Comparison")}

+
+ + + + + + } /> + + + {chartData.map((entry, idx) => ( + + ))} + + + +
+
+ )} + + {/* Ranking */} +
+

๐Ÿ… {t("comparison.rankings", "City Rankings")}

+
+ {rankedCities.map((city) => ( + + ))} +
+
+ + {/* Pollutant Comparison */} + {pollutantData.some((p) => p.readings.some((r) => typeof r.value === "number")) && ( +
+

๐Ÿ”ฌ {t("comparison.pollutantBreakdown", "Pollutant Breakdown")}

+
+ + + + + + {mergedCities.map((c) => ( + + ))} + + + + + {pollutantData.map((p) => ( + + + + {p.readings.map((r) => ( + + ))} + + + ))} + +
{t("comparison.pollutant", "Pollutant")}WHO Limit{c.name.length > 10 ? c.name.slice(0, 10) + "โ€ฆ" : c.name}Average
{p.pollutant} ({p.unit}){p.whoLimit} + {r.value ?? "โ€”"} + {p.average.toFixed(1)}
+
+
+ )} + + {/* Risk Groups */} +
+

๐Ÿ›ก๏ธ {t("comparison.riskGroups", "Health Risk Groups")}

+
+
+

โœ… {t("comparison.safe", "Safe")} (AQI โ‰ค 100)

+

{riskGroups.safe.length > 0 ? riskGroups.safe.join(", ") : "โ€”"}

+
+
+

๐ŸŸก {t("comparison.moderateRisk", "Moderate")} (101โ€“150)

+

{riskGroups.moderate.length > 0 ? riskGroups.moderate.join(", ") : "โ€”"}

+
+
+

๐ŸŸ  {t("comparison.unhealthyRisk", "Unhealthy")} (151โ€“200)

+

{riskGroups.unhealthy.length > 0 ? riskGroups.unhealthy.join(", ") : "โ€”"}

+
+
+

๐Ÿ”ด {t("comparison.criticalRisk", "Critical")} (201+)

+

{riskGroups.critical.length > 0 ? riskGroups.critical.join(", ") : "โ€”"}

+
+
+
+ + {/* Actions */} +
+ + +
+ + {/* Toast */} +
+ {toastMessage} +
+
+
+ ); +} diff --git a/src/components/CityComparisonReport.module.css b/src/components/CityComparisonReport.module.css new file mode 100644 index 0000000..69ba2c2 --- /dev/null +++ b/src/components/CityComparisonReport.module.css @@ -0,0 +1,261 @@ +.root { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1.5rem; + max-width: 1100px; + margin: 0 auto; +} + +.header { text-align: center; } + +.headerTitle { + font-size: 1.6rem; + font-weight: 700; + margin: 0 0 0.25rem; + color: var(--text-primary, #0f172a); +} + +.headerSubtitle { + font-size: 0.95rem; + color: var(--text-secondary, #64748b); + margin: 0; +} + +/* Stats row */ +.statsRow { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; +} + +.statCard { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem 0.5rem; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.65rem; + text-align: center; + transition: transform 0.12s, box-shadow 0.12s; +} + +.statCard:hover { + transform: translateY(-2px); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.06); +} + +.statIcon { font-size: 1.4rem; margin-bottom: 0.2rem; } +.statValue { font-size: 1.3rem; font-weight: 700; color: var(--text-primary, #0f172a); } +.statLabel { font-size: 0.7rem; color: var(--text-secondary, #64748b); margin-top: 0.15rem; } + +/* Ranking list */ +.rankingSection { + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.75rem; + padding: 1.25rem; +} + +.sectionTitle { + font-size: 1.05rem; + font-weight: 600; + margin: 0 0 0.75rem; + color: var(--text-primary, #0f172a); + display: flex; + align-items: center; + gap: 0.45rem; +} + +.rankingList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.rankingItem { + display: grid; + grid-template-columns: 3rem 1fr auto auto; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--bg-secondary, #f8fafc); + border-radius: 0.5rem; + border-left: 4px solid transparent; + transition: transform 0.1s; +} + +.rankingItem:hover { transform: translateX(3px); } +.rankingItem[data-rank="1"] { border-left-color: #fbbf24; background: #fffbeb; } +.rankingItem[data-rank="2"] { border-left-color: #94a3b8; background: #f8fafc; } +.rankingItem[data-rank="3"] { border-left-color: #cd7f32; background: #fdf2e9; } + +.rankBadge { + font-size: 1.1rem; + font-weight: 700; + text-align: center; + color: var(--text-primary, #0f172a); +} + +.cityName { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary, #0f172a); +} + +.aqiValue { + font-size: 1.2rem; + font-weight: 700; + text-align: right; +} + +.bandBadge { + font-size: 0.7rem; + font-weight: 600; + padding: 0.15rem 0.5rem; + border-radius: 999px; + white-space: nowrap; +} + +/* Pollutant comparison */ +.pollutantSection { + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.75rem; + padding: 1.25rem; +} + +.pollutantTable { + width: 100%; + border-collapse: collapse; + font-size: 0.8rem; +} + +.pollutantTable th, +.pollutantTable td { + padding: 0.5rem 0.65rem; + border-bottom: 1px solid var(--border-color, #f1f5f9); + text-align: left; +} + +.pollutantTable th { + background: var(--bg-secondary, #f8fafc); + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary, #475569); +} + +.pollutantTable .exceeds { color: #ef4444; font-weight: 600; } +.pollutantTable .within { color: #22c55e; } + +/* Risk groups */ +.riskSection { + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.75rem; + padding: 1.25rem; +} + +.riskGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 0.75rem; +} + +.riskGroup { + padding: 0.85rem 1rem; + border-radius: 0.5rem; + border-left: 4px solid transparent; +} + +.riskGroupTitle { + font-size: 0.8rem; + font-weight: 600; + margin: 0 0 0.3rem; +} + +.riskGroupCities { + font-size: 0.82rem; + color: var(--text-secondary, #475569); + margin: 0; + line-height: 1.5; +} + +.riskSafe { border-left-color: #22c55e; background: #f0fdf4; } +.riskSafe .riskGroupTitle { color: #16a34a; } +.riskModerate { border-left-color: #eab308; background: #fefce8; } +.riskModerate .riskGroupTitle { color: #ca8a04; } +.riskUnhealthy { border-left-color: #f97316; background: #fff7ed; } +.riskUnhealthy .riskGroupTitle { color: #ea580c; } +.riskCritical { border-left-color: #ef4444; background: #fef2f2; } +.riskCritical .riskGroupTitle { color: #dc2626; } + +/* Actions bar */ +.actionsBar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; +} + +.actionBtn { + padding: 0.55rem 1.25rem; + border: 1px solid var(--border-color, #cbd5e1); + border-radius: 0.5rem; + background: var(--bg-card, #fff); + color: var(--text-primary, #0f172a); + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} + +.actionBtn:hover { + background: var(--bg-secondary, #f8fafc); + border-color: var(--brand, #0d9488); +} + +.actionBtnPrimary { + background: var(--brand, #0d9488); + color: #fff; + border-color: var(--brand, #0d9488); +} + +.actionBtnPrimary:hover { background: #0b8577; } + +/* Empty state */ +.emptyState { + text-align: center; + padding: 3rem 1.5rem; + color: var(--text-secondary, #94a3b8); +} + +.emptyIcon { font-size: 2.5rem; margin-bottom: 0.75rem; } +.emptyTitle { font-size: 1.1rem; font-weight: 600; margin: 0 0 0.3rem; color: var(--text-primary, #475569); } +.emptyDesc { font-size: 0.85rem; margin: 0; max-width: 400px; margin-left: auto; margin-right: auto; line-height: 1.5; } + +/* Toast */ +.toast { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%) translateY(120%); + background: #1e293b; + color: #fff; + padding: 0.65rem 1.25rem; + border-radius: 0.5rem; + font-size: 0.85rem; + font-weight: 500; + z-index: 9999; + opacity: 0; + transition: transform 0.25s ease, opacity 0.25s ease; + pointer-events: none; +} + +.toastVisible { + transform: translateX(-50%) translateY(0); + opacity: 1; +} diff --git a/src/components/CityComparisonReport.test.jsx b/src/components/CityComparisonReport.test.jsx new file mode 100644 index 0000000..5c7191d --- /dev/null +++ b/src/components/CityComparisonReport.test.jsx @@ -0,0 +1,242 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import CityComparisonReport from "./CityComparisonReport"; +import { + getAQIBand, + rankCities, + computeDifferential, + categoriseByRisk, + comparePollutants, + comparisonToCSV, + generateComparisonSummary, +} from "../services/cityComparisonReportService"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key, opts) => (typeof opts === "string" ? opts : opts?.defaultValue || key), + }), +})); + +vi.mock("../hooks/useSWR", () => ({ + useSWR: () => ({ data: null, error: null, isValidating: false, mutate: vi.fn() }), +})); + +vi.mock("../services/airQualityService", () => ({ + fetchCityComparisons: vi.fn(async () => []), +})); + +vi.mock("../services/dataExportService", () => ({ + triggerDownload: vi.fn(), + copyToClipboard: vi.fn(async () => true), +})); + +// --------------------------------------------------------------------------- +// Service tests +// --------------------------------------------------------------------------- +describe("cityComparisonReportService", () => { + describe("getAQIBand", () => { + it("returns Good for AQI โ‰ค 50", () => { + expect(getAQIBand(25).label).toBe("Good"); + expect(getAQIBand(25).color).toBe("#22c55e"); + }); + + it("returns Moderate for 51โ€“100", () => { + expect(getAQIBand(75).label).toBe("Moderate"); + }); + + it("returns USG for 101โ€“150", () => { + expect(getAQIBand(125).label).toBe("Unhealthy for Sensitive Groups"); + }); + + it("returns Unhealthy for 151โ€“200", () => { + expect(getAQIBand(175).label).toBe("Unhealthy"); + }); + + it("returns Very Unhealthy for 201โ€“300", () => { + expect(getAQIBand(250).label).toBe("Very Unhealthy"); + }); + + it("returns Hazardous for 301+", () => { + expect(getAQIBand(400).label).toBe("Hazardous"); + }); + + it("returns Unknown for null", () => { + expect(getAQIBand(null).label).toBe("Unknown"); + }); + }); + + describe("rankCities", () => { + it("ranks cities by AQI ascending", () => { + const cities = [ + { name: "Delhi", aqi: 150 }, + { name: "Mumbai", aqi: 80 }, + { name: "Chennai", aqi: 120 }, + ]; + const ranked = rankCities(cities); + expect(ranked.length).toBe(3); + expect(ranked[0].name).toBe("Mumbai"); + expect(ranked[0].rank).toBe(1); + expect(ranked[1].name).toBe("Chennai"); + expect(ranked[2].name).toBe("Delhi"); + }); + + it("filters out cities with null AQI", () => { + const cities = [ + { name: "A", aqi: 100 }, + { name: "B", aqi: null }, + { name: "C", aqi: 50 }, + ]; + const ranked = rankCities(cities); + expect(ranked.length).toBe(2); + expect(ranked[0].name).toBe("C"); + }); + + it("returns empty for empty input", () => { + expect(rankCities([])).toEqual([]); + expect(rankCities(null)).toEqual([]); + }); + }); + + describe("computeDifferential", () => { + it("computes positive differential", () => { + const result = computeDifferential( + { name: "Delhi", aqi: 150 }, + { name: "Mumbai", aqi: 80 }, + ); + expect(result.diff).toBe(70); + expect(result.worseCity).toBe("Delhi"); + expect(result.summary).toContain("higher"); + }); + + it("computes negative differential", () => { + const result = computeDifferential( + { name: "Mumbai", aqi: 80 }, + { name: "Delhi", aqi: 150 }, + ); + expect(result.diff).toBe(-70); + expect(result.worseCity).toBe("Delhi"); + }); + + it("handles identical AQI", () => { + const result = computeDifferential( + { name: "A", aqi: 100 }, + { name: "B", aqi: 100 }, + ); + expect(result.diff).toBe(0); + expect(result.worseCity).toBeNull(); + }); + + it("handles null AQI", () => { + const result = computeDifferential( + { name: "A", aqi: null }, + { name: "B", aqi: 100 }, + ); + expect(result.worseCity).toBe("B"); + }); + }); + + describe("categoriseByRisk", () => { + it("categorises cities correctly", () => { + const cities = [ + { name: "A", aqi: 50 }, + { name: "B", aqi: 120 }, + { name: "C", aqi: 175 }, + { name: "D", aqi: 250 }, + ]; + const groups = categoriseByRisk(cities); + expect(groups.safe).toEqual(["A"]); + expect(groups.moderate).toEqual(["B"]); + expect(groups.unhealthy).toEqual(["C"]); + expect(groups.critical).toEqual(["D"]); + }); + }); + + describe("comparePollutants", () => { + it("returns pollutant breakdown with readings", () => { + const cities = [ + { name: "A", pm2_5: 20, pm10: 40, no2: 30, o3: 80, co: 1.5 }, + { name: "B", pm2_5: 50, pm10: 60, no2: 15, o3: 90, co: 0.8 }, + ]; + const result = comparePollutants(cities); + expect(result.length).toBe(5); + const pm25 = result.find((p) => p.key === "pm2_5"); + expect(pm25.readings.length).toBe(2); + expect(pm25.average).toBe(35); + expect(pm25.citiesExceedingLimit).toContain("B"); // 50 > 15 + }); + }); + + describe("comparisonToCSV", () => { + it("generates valid CSV", () => { + const ranked = [ + { rank: 1, name: "A", aqi: 50, band: { label: "Good" }, risk: "low", pm2_5: 10, pm10: 20, no2: 15, o3: 30, co: 0.5, relativeDiff: "0.0" }, + { rank: 2, name: "B", aqi: 100, band: { label: "Moderate" }, risk: "low", pm2_5: 30, pm10: 50, no2: 25, o3: 40, co: 0.8, relativeDiff: "100.0" }, + ]; + const csv = comparisonToCSV(ranked); + expect(csv).toContain("Rank,City,US AQI"); + expect(csv).toContain('"A"'); + expect(csv).toContain('"B"'); + }); + }); + + describe("generateComparisonSummary", () => { + it("generates readable summary", () => { + const ranked = [ + { rank: 1, name: "Mumbai", aqi: 60, band: { label: "Moderate" }, relativeDiff: "0.0" }, + { rank: 2, name: "Delhi", aqi: 150, band: { label: "Unhealthy for Sensitive Groups" }, relativeDiff: "150.0" }, + ]; + const summary = generateComparisonSummary(ranked); + expect(summary).toContain("MULTI-CITY AIR QUALITY COMPARISON"); + expect(summary).toContain("Mumbai"); + expect(summary).toContain("Delhi"); + expect(summary).toContain("๐Ÿฅ‡"); + }); + + it("returns message for empty input", () => { + expect(generateComparisonSummary([])).toBe("No cities to compare."); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Component tests +// --------------------------------------------------------------------------- +describe("CityComparisonReport", () => { + it("renders empty state when no saved locations", () => { + render(); + expect(screen.getByTestId("city-comparison-report")).toBeTruthy(); + expect(screen.getByText(/No saved cities yet/)).toBeTruthy(); + }); + + it("renders panel with title when cities are saved", () => { + const locations = [ + { name: "Mumbai", lat: 19.07, lon: 72.87 }, + { name: "Chennai", lat: 13.08, lon: 80.27 }, + ]; + render(); + expect(screen.getByTestId("city-comparison-report")).toBeTruthy(); + expect(screen.getByText(/City Comparison Report/)).toBeTruthy(); + }); + + it("displays ranking section", () => { + const locations = [{ name: "Mumbai", lat: 19.07, lon: 72.87 }]; + render(); + expect(screen.getByTestId("ranking-section")).toBeTruthy(); + }); + + it("displays risk section", () => { + const locations = [{ name: "Mumbai", lat: 19.07, lon: 72.87 }]; + render(); + expect(screen.getByTestId("risk-section")).toBeTruthy(); + }); + + it("displays action buttons", () => { + const locations = [{ name: "Mumbai", lat: 19.07, lon: 72.87 }]; + render(); + expect(screen.getByTestId("export-csv-btn")).toBeTruthy(); + expect(screen.getByTestId("copy-report-btn")).toBeTruthy(); + }); +}); diff --git a/src/components/DataExportDashboard.jsx b/src/components/DataExportDashboard.jsx new file mode 100644 index 0000000..46fc4d5 --- /dev/null +++ b/src/components/DataExportDashboard.jsx @@ -0,0 +1,428 @@ +import { useState, useMemo, useCallback, memo, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { + trendToCSV, + trendToJSON, + generateTextReport, + generateShareableLink, + triggerDownload, + copyToClipboard, + computeSummaryStats, +} from "../services/dataExportService"; +import styles from "./DataExportDashboard.module.css"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const EXPORT_FORMATS = [ + { + id: "csv", + icon: "๐Ÿ“„", + title: "CSV Spreadsheet", + desc: "Download trend data as a CSV file compatible with Excel, Google Sheets, or any data tool.", + ext: ".csv", + mime: "text/csv", + }, + { + id: "json", + icon: "๐Ÿ”ง", + title: "JSON Data", + desc: "Structured JSON export with metadata โ€” ideal for APIs, dashboards, or further processing.", + ext: ".json", + mime: "application/json", + }, + { + id: "text", + icon: "๐Ÿ“‹", + title: "Text Report", + desc: "Human-readable report with health guidance. Copy to clipboard or download as .txt.", + ext: ".txt", + mime: "text/plain", + }, +]; + +const TIME_RANGES = [ + { label: "Last 6h", hours: 6 }, + { label: "Last 12h", hours: 12 }, + { label: "Last 24h", hours: 24 }, + { label: "All Data", hours: Infinity }, +]; + +// --------------------------------------------------------------------------- +// AQI band helpers +// --------------------------------------------------------------------------- + +function aqiClassName(aqi) { + if (aqi == null) return ""; + if (aqi <= 50) return styles.aqiGood; + if (aqi <= 100) return styles.aqiModerate; + if (aqi <= 150) return styles.aqiUSG; + if (aqi <= 200) return styles.aqiUnhealthy; + if (aqi <= 300) return styles.aqiVeryUnhealthy; + return styles.aqiHazardous; +} + +// --------------------------------------------------------------------------- +// Memoized sub-components +// --------------------------------------------------------------------------- + +const StatCard = memo(function StatCard({ icon, value, label, color }) { + return ( +
+ + {value} + {label} +
+ ); +}); + +const ExportCard = memo(function ExportCard({ format, onExport }) { + const handleClick = useCallback(() => onExport(format.id), [format.id, onExport]); + return ( + + ); +}); + +function MiniTooltip({ active, payload }) { + if (!active || !payload || payload.length === 0) return null; + const d = payload[0]?.payload; + if (!d) return null; + return ( +
+
AQI: {d.us_aqi ?? "โ€”"}
+
+ {d.time ? new Date(d.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function DataExportDashboard({ trend, current, cityName, position }) { + const { t } = useTranslation(); + const [timeRange, setTimeRange] = useState(24); + const [showReportModal, setShowReportModal] = useState(false); + const [toastMessage, setToastMessage] = useState(""); + const [copiedLink, setCopiedLink] = useState(false); + + // Filter trend by time range + const filteredTrend = useMemo(() => { + if (!Array.isArray(trend) || trend.length === 0) return []; + if (timeRange === Infinity) return trend; + const cutoff = Date.now() - timeRange * 60 * 60 * 1000; + return trend.filter((p) => { + if (!p?.time) return false; + try { return new Date(p.time).getTime() >= cutoff; } catch { return false; } + }); + }, [trend, timeRange]); + + // Summary stats + const stats = useMemo(() => computeSummaryStats(filteredTrend), [filteredTrend]); + + // Mini chart data + const chartData = useMemo(() => + filteredTrend + .filter((d) => d?.time && typeof d.us_aqi === "number") + .map((d) => ({ + time: d.time, + us_aqi: d.us_aqi, + pm2_5: d.pm2_5, + })), + [filteredTrend], + ); + + // Shareable link + const shareableLink = useMemo(() => + generateShareableLink(cityName, position?.lat, position?.lon), + [cityName, position], + ); + + // Toast helper + const showToast = useCallback((msg) => { + setToastMessage(msg); + setTimeout(() => setToastMessage(""), 2500); + }, []); + + // Export handlers + const handleExport = useCallback((formatId) => { + const safeCityName = (cityName || "unknown").replace(/[^a-z0-9]/gi, "-").toLowerCase(); + const ts = new Date().toISOString().slice(0, 10); + const fmt = EXPORT_FORMATS.find((f) => f.id === formatId); + + switch (formatId) { + case "csv": { + const csv = trendToCSV(filteredTrend, cityName); + triggerDownload(csv, `${safeCityName}-aqi-${ts}${fmt.ext}`, fmt.mime); + showToast(t("export.downloadedCSV", "CSV downloaded!")); + break; + } + case "json": { + const json = trendToJSON(filteredTrend, cityName, position); + triggerDownload(json, `${safeCityName}-aqi-${ts}${fmt.ext}`, fmt.mime); + showToast(t("export.downloadedJSON", "JSON downloaded!")); + break; + } + case "text": { + setShowReportModal(true); + break; + } + default: + break; + } + }, [filteredTrend, cityName, position, t, showToast]); + + const handleCopyTextReport = useCallback(async () => { + const report = generateTextReport(current, cityName, position); + const ok = await copyToClipboard(report); + showToast(ok ? t("export.copiedReport", "Report copied to clipboard!") : t("export.copyFailed", "Copy failed")); + setShowReportModal(false); + }, [current, cityName, position, t, showToast]); + + const handleDownloadTextReport = useCallback(() => { + const report = generateTextReport(current, cityName, position); + const safeCityName = (cityName || "unknown").replace(/[^a-z0-9]/gi, "-").toLowerCase(); + triggerDownload(report, `${safeCityName}-report-${new Date().toISOString().slice(0, 10)}.txt`, "text/plain"); + showToast(t("export.downloadedReport", "Report downloaded!")); + setShowReportModal(false); + }, [current, cityName, position, t, showToast]); + + const handleCopyLink = useCallback(async () => { + const ok = await copyToClipboard(shareableLink); + setCopiedLink(true); + showToast(ok ? t("export.linkCopied", "Link copied!") : t("export.copyFailed", "Copy failed")); + setTimeout(() => setCopiedLink(false), 2000); + }, [shareableLink, t, showToast]); + + // Escape key closes modal + useEffect(() => { + if (!showReportModal) return; + const handler = (e) => { if (e.key === "Escape") setShowReportModal(false); }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [showReportModal]); + + const textReport = useMemo( + () => generateTextReport(current, cityName, position), + [current, cityName, position], + ); + + // --- Empty state --- + if (!current || !Array.isArray(trend) || trend.length === 0) { + return ( +
+
+
+

๐Ÿ“ฅ {t("export.title", "Data Export & Reports")}

+

{t("export.subtitle", "Download, share, and export your air quality data")}

+
+
+
๐Ÿ“Š
+

{t("export.noData", "No data to export")}

+

{t("export.noDataDesc", "Once AQI data is available for your selected city, you can export it here.")}

+
+
+
+ ); + } + + const reportText = generateTextReport(current, cityName, position); + + return ( +
+
+ {/* Header */} +
+

๐Ÿ“ฅ {t("export.title", "Data Export & Reports")}

+

+ {t("export.subtitleCity", "Download, share, and export air quality data for {{city}}", { city: cityName })} +

+
+ + {/* Summary Stats */} +
+ + + + + + +
+ + {/* Mini Chart */} + {chartData.length > 2 && ( +
+

๐Ÿ“ˆ {t("export.trendPreview", "Trend Preview")}

+
+ + + + { try { return new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } catch { return ""; } }} + interval="preserveStartEnd" + /> + + } /> + + + +
+
+ )} + + {/* Filter + Data Preview */} +
+
+

๐Ÿ“‹ {t("export.dataPreview", "Data Preview")}

+
+ {t("export.timeRange", "Range:")} + +
+
+
+ + + + + + + + + + + + + + {filteredTrend.map((point, idx) => ( + + + + + + + + + + ))} + {filteredTrend.length === 0 && ( + + )} + +
{t("export.colTime", "Time")}US AQIPM2.5PM10NOโ‚‚Oโ‚ƒCO
{point.time ? new Date(point.time).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "โ€”"}{point.us_aqi ?? "โ€”"}{point.pm2_5 ?? "โ€”"}{point.pm10 ?? "โ€”"}{point.nitrogen_dioxide ?? "โ€”"}{point.ozone ?? "โ€”"}{point.carbon_monoxide ?? "โ€”"}
+ {t("export.noRowsInRange", "No data in selected time range")} +
+
+

+ {filteredTrend.length} {t("export.of", "of")} {trend.length} {t("export.rowsShown", "rows shown")} +

+
+ + {/* Export Actions */} +
+

๐Ÿš€ {t("export.exportActions", "Export Options")}

+
+ {EXPORT_FORMATS.map((fmt) => ( + + ))} +
+
+ + {/* Shareable Link */} +
+

๐Ÿ”— {t("export.shareLink", "Shareable Link")}

+

+ {t("export.shareLinkDesc", "Share this link so others can view the same city's air quality data.")} +

+
+ + +
+
+ + {/* Text Report Preview Modal */} + {showReportModal && ( +
{ if (e.target === e.currentTarget) setShowReportModal(false); }} role="dialog" aria-modal="true" aria-label="Text Report"> +
+
+

๐Ÿ“‹ {t("export.reportPreview", "Report Preview")}

+ +
+
+
{reportText}
+
+
+ + +
+
+
+ )} + + {/* Toast */} +
+ {toastMessage} +
+
+
+ ); +} diff --git a/src/components/DataExportDashboard.module.css b/src/components/DataExportDashboard.module.css new file mode 100644 index 0000000..6114ce6 --- /dev/null +++ b/src/components/DataExportDashboard.module.css @@ -0,0 +1,367 @@ +/* DataExportDashboard.module.css */ + +.root { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1.5rem; + max-width: 1100px; + margin: 0 auto; +} + +.header { text-align: center; } + +.headerTitle { + font-size: 1.6rem; + font-weight: 700; + margin: 0 0 0.25rem; + color: var(--text-primary, #0f172a); +} + +.headerSubtitle { + font-size: 0.95rem; + color: var(--text-secondary, #64748b); + margin: 0; +} + +.statsRow { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 0.75rem; +} + +.statCard { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.85rem 0.5rem; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.65rem; + text-align: center; + transition: transform 0.12s, box-shadow 0.12s; +} + +.statCard:hover { + transform: translateY(-2px); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.06); +} + +.statIcon { font-size: 1.3rem; margin-bottom: 0.2rem; } + +.statValue { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary, #0f172a); + line-height: 1.2; +} + +.statLabel { + font-size: 0.7rem; + color: var(--text-secondary, #64748b); + margin-top: 0.15rem; +} + +.previewSection { + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.75rem; + padding: 1.25rem; + overflow: hidden; +} + +.sectionTitle { + font-size: 1.05rem; + font-weight: 600; + margin: 0 0 0.75rem; + color: var(--text-primary, #0f172a); + display: flex; + align-items: center; + gap: 0.45rem; +} + +.tableScroll { + overflow-x: auto; + border-radius: 0.5rem; + border: 1px solid var(--border-color, #e2e8f0); +} + +.dataTable { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; + white-space: nowrap; +} + +.dataTable th, +.dataTable td { + padding: 0.45rem 0.65rem; + border-bottom: 1px solid var(--border-color, #f1f5f9); + text-align: left; +} + +.dataTable th { + background: var(--bg-secondary, #f8fafc); + font-weight: 600; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary, #475569); + position: sticky; + top: 0; +} + +.dataTable tbody tr:hover { background: var(--bg-secondary, #f8fafc); } + +.dataTable .aqiGood { color: #22c55e; font-weight: 600; } +.dataTable .aqiModerate { color: #eab308; font-weight: 600; } +.dataTable .aqiUSG { color: #f97316; font-weight: 600; } +.dataTable .aqiUnhealthy { color: #ef4444; font-weight: 600; } +.dataTable .aqiVeryUnhealthy { color: #9333ea; font-weight: 600; } +.dataTable .aqiHazardous { color: #7f1d1d; font-weight: 600; } + +.exportGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.exportCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; + padding: 1.25rem 1rem; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.75rem; + text-align: center; + transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s; + cursor: pointer; +} + +.exportCard:hover { + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1); + border-color: var(--brand, #0d9488); +} + +.exportCard:active { transform: translateY(-1px); } + +.exportIcon { font-size: 2rem; } + +.exportTitle { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary, #0f172a); + margin: 0; +} + +.exportDesc { + font-size: 0.78rem; + color: var(--text-secondary, #64748b); + margin: 0; + line-height: 1.4; +} + +.linkSection { + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.75rem; + padding: 1.25rem; +} + +.linkRow { + display: flex; + gap: 0.5rem; + align-items: stretch; + margin-top: 0.75rem; +} + +.linkInput { + flex: 1; + padding: 0.55rem 0.75rem; + border: 1px solid var(--border-color, #cbd5e1); + border-radius: 0.5rem; + font-size: 0.8rem; + color: var(--text-primary, #0f172a); + background: var(--bg-secondary, #f8fafc); + font-family: monospace; +} + +.linkInput:focus { + outline: 2px solid var(--brand, #0d9488); + outline-offset: 2px; +} + +.copyBtn { + padding: 0.55rem 1rem; + border: none; + border-radius: 0.5rem; + background: var(--brand, #0d9488); + color: #fff; + font-weight: 600; + font-size: 0.82rem; + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} + +.copyBtn:hover { background: #0b8577; } +.copyBtn:active { background: #097366; } +.copyBtnSuccess { background: #22c55e; } + +.toast { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%) translateY(120%); + background: #1e293b; + color: #fff; + padding: 0.65rem 1.25rem; + border-radius: 0.5rem; + font-size: 0.85rem; + font-weight: 500; + z-index: 9999; + opacity: 0; + transition: transform 0.25s ease, opacity 0.25s ease; + pointer-events: none; +} + +.toastVisible { + transform: translateX(-50%) translateY(0); + opacity: 1; +} + +.emptyState { + text-align: center; + padding: 3rem 1.5rem; + color: var(--text-secondary, #94a3b8); +} + +.emptyIcon { font-size: 2.5rem; margin-bottom: 0.75rem; } + +.emptyTitle { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 0.3rem; + color: var(--text-primary, #475569); +} + +.emptyDesc { + font-size: 0.85rem; + margin: 0; + max-width: 400px; + margin-left: auto; + margin-right: auto; + line-height: 1.5; +} + +.filterBar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + padding: 0.75rem 1rem; + background: var(--bg-secondary, #f8fafc); + border-radius: 0.5rem; +} + +.filterLabel { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary, #64748b); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.filterSelect { + padding: 0.35rem 0.6rem; + border-radius: 0.4rem; + border: 1px solid var(--border-color, #cbd5e1); + background: var(--bg-card, #fff); + font-size: 0.82rem; + color: var(--text-primary, #0f172a); +} + +.filterSelect:focus { + outline: 2px solid var(--brand, #0d9488); + outline-offset: 1px; +} + +.modalOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 9000; + padding: 1rem; +} + +.modal { + background: var(--bg-card, #fff); + border-radius: 0.75rem; + max-width: 600px; + width: 100%; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); +} + +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border-color, #e2e8f0); +} + +.modalTitle { + font-size: 1rem; + font-weight: 600; + margin: 0; + color: var(--text-primary, #0f172a); +} + +.modalClose { + background: none; + border: none; + font-size: 1.3rem; + cursor: pointer; + color: var(--text-secondary, #64748b); + padding: 0.2rem; + line-height: 1; +} + +.modalClose:hover { color: var(--text-primary, #0f172a); } + +.modalBody { + padding: 1.25rem; + overflow-y: auto; + flex: 1; +} + +.modalFooter { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--border-color, #e2e8f0); +} + +.reportPreview { + font-family: 'Courier New', Courier, monospace; + font-size: 0.78rem; + line-height: 1.5; + white-space: pre-wrap; + background: var(--bg-secondary, #f8fafc); + padding: 1rem; + border-radius: 0.5rem; + border: 1px solid var(--border-color, #e2e8f0); + color: var(--text-primary, #0f172a); + max-height: 400px; + overflow-y: auto; +} diff --git a/src/components/DataExportDashboard.test.jsx b/src/components/DataExportDashboard.test.jsx new file mode 100644 index 0000000..6191866 --- /dev/null +++ b/src/components/DataExportDashboard.test.jsx @@ -0,0 +1,177 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import DataExportDashboard from "./DataExportDashboard"; +import { + trendToCSV, + trendToJSON, + generateTextReport, + generateShareableLink, + computeSummaryStats, +} from "../services/dataExportService"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key, opts) => (typeof opts === "string" ? opts : opts?.defaultValue || key), + }), +})); + +// --------------------------------------------------------------------------- +// Service tests +// --------------------------------------------------------------------------- +describe("dataExportService", () => { + const sampleTrend = [ + { time: "2026-08-28T08:00:00Z", us_aqi: 85, pm2_5: 30, pm10: 50, nitrogen_dioxide: 20, ozone: 35, carbon_monoxide: 0.6 }, + { time: "2026-08-28T09:00:00Z", us_aqi: 92, pm2_5: 35, pm10: 55, nitrogen_dioxide: 22, ozone: 38, carbon_monoxide: 0.7 }, + { time: "2026-08-28T10:00:00Z", us_aqi: 110, pm2_5: 45, pm10: 70, nitrogen_dioxide: 28, ozone: 42, carbon_monoxide: 0.9 }, + ]; + + describe("trendToCSV", () => { + it("produces a CSV with header and data rows", () => { + const csv = trendToCSV(sampleTrend, "Delhi"); + expect(csv).toContain("# Air Quality Data Export"); + expect(csv).toContain("Timestamp,US AQI"); + expect(csv).toContain("85,30,50,20,35,0.6"); + expect(csv).toContain("Delhi"); + }); + + it("handles empty input gracefully", () => { + const csv = trendToCSV([], "Test"); + expect(csv).toContain("Timestamp,US AQI"); + expect(csv.split("\n").length).toBe(2); // header comment + header row only + }); + + it("handles null input", () => { + const csv = trendToCSV(null); + expect(csv).toContain("Timestamp,US AQI"); + }); + }); + + describe("trendToJSON", () => { + it("produces valid JSON with expected structure", () => { + const json = trendToJSON(sampleTrend, "Mumbai", { lat: 19.07, lon: 72.87 }); + const parsed = JSON.parse(json); + expect(parsed.exportVersion).toBe("1.0"); + expect(parsed.city).toBe("Mumbai"); + expect(parsed.coordinates.latitude).toBe(19.07); + expect(parsed.dataPoints.length).toBe(3); + expect(parsed.dataPoints[0].aqi.us_aqi).toBe(85); + expect(parsed.metadata.totalDataPoints).toBe(3); + }); + + it("handles missing position", () => { + const json = trendToJSON(sampleTrend, "Test", null); + const parsed = JSON.parse(json); + expect(parsed.coordinates.latitude).toBeNull(); + }); + }); + + describe("generateTextReport", () => { + it("generates a readable report with AQI band", () => { + const current = { us_aqi: 120, pm2_5: 45, pm10: 70, nitrogen_dioxide: 28, ozone: 42, carbon_monoxide: 0.9 }; + const report = generateTextReport(current, "Delhi", { lat: 28.61, lon: 77.21 }); + expect(report).toContain("AIR QUALITY REPORT"); + expect(report).toContain("DELHI"); + expect(report).toContain("120"); + expect(report).toContain("Unhealthy for Sensitive Groups"); + expect(report).toContain("PM2.5"); + expect(report).toContain("28.61"); + }); + + it("returns no-data message for null current", () => { + expect(generateTextReport(null, "Test")).toBe("No data available."); + }); + }); + + describe("generateShareableLink", () => { + it("produces a URL with city params", () => { + const link = generateShareableLink("Chennai", 13.08, 80.27); + expect(link).toContain("city=Chennai"); + expect(link).toContain("lat=13.08"); + expect(link).toContain("lon=80.27"); + }); + }); + + describe("computeSummaryStats", () => { + it("computes correct stats", () => { + const stats = computeSummaryStats(sampleTrend); + expect(stats.count).toBe(3); + expect(stats.avgAqi).toBeCloseTo(95.67, 0); + expect(stats.maxAqi).toBe(110); + expect(stats.minAqi).toBe(85); + expect(stats.avgPm25).toBeCloseTo(36.67, 0); + }); + + it("returns zeros for empty input", () => { + const stats = computeSummaryStats([]); + expect(stats.count).toBe(0); + expect(stats.avgAqi).toBe(0); + }); + + it("handles null input", () => { + const stats = computeSummaryStats(null); + expect(stats.count).toBe(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Component tests +// --------------------------------------------------------------------------- +describe("DataExportDashboard", () => { + const sampleTrend = [ + { time: "2026-08-28T08:00:00Z", us_aqi: 85, pm2_5: 30, pm10: 50, nitrogen_dioxide: 20, ozone: 35, carbon_monoxide: 0.6 }, + { time: "2026-08-28T09:00:00Z", us_aqi: 92, pm2_5: 35, pm10: 55, nitrogen_dioxide: 22, ozone: 38, carbon_monoxide: 0.7 }, + { time: "2026-08-28T10:00:00Z", us_aqi: 110, pm2_5: 45, pm10: 70, nitrogen_dioxide: 28, ozone: 42, carbon_monoxide: 0.9 }, + ]; + + const current = { us_aqi: 110, pm2_5: 45, pm10: 70, nitrogen_dioxide: 28, ozone: 42, carbon_monoxide: 0.9 }; + + const defaultProps = { + trend: sampleTrend, + current, + cityName: "Delhi", + position: { lat: 28.61, lon: 77.21 }, + }; + + it("renders the panel with title", () => { + render(); + expect(screen.getByTestId("data-export-dashboard")).toBeTruthy(); + expect(screen.getByText(/Data Export/)).toBeTruthy(); + }); + + it("displays stats row", () => { + render(); + expect(screen.getByTestId("stats-row")).toBeTruthy(); + }); + + it("displays data preview table", () => { + render(); + expect(screen.getByTestId("data-preview")).toBeTruthy(); + }); + + it("displays shareable link section", () => { + render(); + expect(screen.getByTestId("shareable-link-section")).toBeTruthy(); + expect(screen.getByTestId("shareable-link-input")).toBeTruthy(); + }); + + it("renders all three export cards", () => { + render(); + expect(screen.getByTestId("export-csv")).toBeTruthy(); + expect(screen.getByTestId("export-json")).toBeTruthy(); + expect(screen.getByTestId("export-text")).toBeTruthy(); + }); + + it("shows empty state when no data", () => { + render(); + expect(screen.getByText(/No data to export/)).toBeTruthy(); + }); + + it("shows time range filter", () => { + render(); + expect(screen.getByTestId("time-range-select")).toBeTruthy(); + }); +}); diff --git a/src/components/ExposureTimelineTracker.jsx b/src/components/ExposureTimelineTracker.jsx new file mode 100644 index 0000000..4e06baa --- /dev/null +++ b/src/components/ExposureTimelineTracker.jsx @@ -0,0 +1,401 @@ +import { useState, useMemo, useCallback, memo, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, + ReferenceLine, +} from "recharts"; +import { + recordExposure, + readExposureHistory, + computeDailySummaries, + computeWeeklySummaries, + computeHealthScore, + generateRecommendations, + getRiskMeta, + clearExposureHistory, + exposureToCSV, +} from "../services/exposureTimelineService"; +import { triggerDownload, copyToClipboard } from "../services/dataExportService"; +import styles from "./ExposureTimelineTracker.module.css"; + +// --------------------------------------------------------------------------- +// Memoized sub-components +// --------------------------------------------------------------------------- + +const StatCard = memo(function StatCard({ icon, value, label, color }) { + return ( +
+ + {value} + {label} +
+ ); +}); + +const RecItem = memo(function RecItem({ rec }) { + const cls = rec.priority === "high" ? styles.recHigh + : rec.priority === "medium" ? styles.recMedium + : rec.priority === "low" ? styles.recLow + : styles.recInfo; + return ( +
  • + +
    +

    {rec.title}

    +

    {rec.description}

    +
    +
  • + ); +}); + +function ScoreRing({ score, color }) { + const radius = 65; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (score / 100) * circumference; + + return ( +
    + + + + +
    +
    {score}
    +
    / 100
    +
    +
    + ); +} + +function ChartTooltip({ active, payload, label }) { + if (!active || !payload || payload.length === 0) return null; + return ( +
    +
    {label}
    + {payload.map((e) => ( +
    + {e.name}: {e.value} +
    + ))} +
    + ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +export default function ExposureTimelineTracker({ current, cityName }) { + const { t } = useTranslation(); + const [history, setHistory] = useState(() => readExposureHistory()); + const [activeTab, setActiveTab] = useState("daily"); + const [toastMessage, setToastMessage] = useState(""); + + // Record current AQI on mount / when current changes + useEffect(() => { + if (current?.us_aqi != null) { + const updated = recordExposure(current.us_aqi, cityName); + setHistory(updated); + } + }, [current?.us_aqi, cityName]); + + // Aggregations + const dailySummaries = useMemo(() => computeDailySummaries(history), [history]); + const weeklySummaries = useMemo(() => computeWeeklySummaries(history), [history]); + const healthScore = useMemo(() => computeHealthScore(history), [history]); + const recommendations = useMemo(() => generateRecommendations(history), [history]); + + // Chart data (daily) + const dailyChartData = useMemo(() => + dailySummaries.slice(-14).map((d) => ({ + date: d.date.slice(5), // MM-DD + avgAqi: d.avgAqi, + maxAqi: d.maxAqi, + riskLevel: d.riskLevel, + })), + [dailySummaries], + ); + + // Stats + const totalDays = dailySummaries.length; + const totalHours = history.length; + const overallAvg = dailySummaries.length > 0 + ? Math.round(dailySummaries.reduce((s, d) => s + d.avgAqi, 0) / dailySummaries.length) + : 0; + const peakDay = dailySummaries.reduce((max, d) => d.maxAqi > (max?.maxAqi ?? 0) ? d : max, null); + + const showToast = useCallback((msg) => { + setToastMessage(msg); + setTimeout(() => setToastMessage(""), 2500); + }, []); + + const handleExportCSV = useCallback(() => { + const csv = exposureToCSV(history); + triggerDownload(csv, `exposure-history-${new Date().toISOString().slice(0, 10)}.csv`, "text/csv"); + showToast(t("exposure.downloaded", "Export downloaded!")); + }, [history, t, showToast]); + + const handleCopySummary = useCallback(async () => { + const score = healthScore; + const lines = [ + 'โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', + ' EXPOSURE TIMELINE SUMMARY', + ` Generated: ${new Date().toLocaleString()}`, + 'โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', + '', + ` Health Score: ${score.score}/100 (${score.label})`, + ` Total days tracked: ${totalDays}`, + ` Total data points: ${totalHours}`, + ` Overall average AQI: ${overallAvg}`, + peakDay ? ` Peak day: ${peakDay.date} (AQI ${peakDay.maxAqi})` : '', + '', + ' โ”€โ”€โ”€ Recommendations โ”€โ”€โ”€', + ...recommendations.map((r) => ` ${r.icon} ${r.title}: ${r.description}`), + '', + 'โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', + ' Source: Pollution Control Hub', + 'โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', + ]; + const ok = await copyToClipboard(lines.filter(Boolean).join("\n")); + showToast(ok ? t("exposure.copied", "Summary copied!") : t("exposure.copyFailed", "Copy failed")); + }, [healthScore, totalDays, totalHours, overallAvg, peakDay, recommendations, t, showToast]); + + const handleClearHistory = useCallback(() => { + if (typeof window !== "undefined" && window.confirm("Clear all exposure history? This cannot be undone.")) { + clearExposureHistory(); + setHistory([]); + showToast(t("exposure.cleared", "History cleared")); + } + }, [t, showToast]); + + // --- Empty state --- + if (history.length === 0 && (!current || current.us_aqi == null)) { + return ( +
    +
    +
    +

    โฑ๏ธ {t("exposure.title", "Exposure Timeline")}

    +

    {t("exposure.subtitle", "Track your cumulative pollution exposure over time")}

    +
    +
    +
    โฑ๏ธ
    +

    {t("exposure.noData", "No exposure data yet")}

    +

    {t("exposure.noDataDesc", "Your exposure timeline builds automatically as you use the app. Check back after a few hours to see your exposure patterns.")}

    +
    +
    +
    + ); + } + + return ( +
    +
    + {/* Header */} +
    +

    โฑ๏ธ {t("exposure.title", "Exposure Timeline")}

    +

    {t("exposure.subtitleTrack", "Track your cumulative pollution exposure over time โ€” {{city}}", { city: cityName })}

    +
    + + {/* Health Score Ring */} +
    +
    + +

    + {t("exposure.healthScore", "7-Day Health Score")}: {healthScore.label} +

    +

    + {t("exposure.healthScoreDesc", "Based on your rolling 7-day average AQI exposure")} +

    +
    +
    + + {/* Stats */} +
    + + + + {peakDay && ( + + )} +
    + + {/* Daily chart */} + {dailyChartData.length > 1 && ( +
    +

    ๐Ÿ“ˆ {t("exposure.dailyChart", "Daily Average AQI")}

    +
    + + + + + + } /> + + + + + {dailyChartData.map((entry, idx) => { + const risk = getRiskMeta(entry.riskLevel); + return ; + })} + + + +
    +
    + )} + + {/* Tabbed view: Daily / Weekly */} +
    +
    + + +
    + +
    + + + + {activeTab === "daily" ? ( + <> + + + + + + + + + ) : ( + <> + + + + + + + + )} + + + + {activeTab === "daily" ? ( + dailySummaries.length > 0 ? ( + [...dailySummaries].reverse().map((d) => { + const risk = getRiskMeta(d.riskLevel); + const riskCls = d.riskLevel === "low" ? styles.riskLow + : d.riskLevel === "moderate" ? styles.riskModerate + : d.riskLevel === "high" ? styles.riskHigh + : styles.riskCritical; + return ( + + + + + + + + + + ); + }) + ) : ( + + ) + ) : ( + weeklySummaries.length > 0 ? ( + [...weeklySummaries].reverse().map((w) => { + const risk = getRiskMeta(w.riskLevel); + const riskCls = w.riskLevel === "low" ? styles.riskLow + : w.riskLevel === "moderate" ? styles.riskModerate + : w.riskLevel === "high" ? styles.riskHigh + : styles.riskCritical; + return ( + + + + + + + + + ); + }) + ) : ( + + ) + )} + +
    {t("exposure.colDate", "Date")}{t("exposure.colAvg", "Avg AQI")}{t("exposure.colPeak", "Peak")}{t("exposure.colLow", "Low")}{t("exposure.colHours", "Hours")}{t("exposure.colScore", "Exposure")}{t("exposure.colRisk", "Risk")}{t("exposure.colWeek", "Week Starting")}{t("exposure.colAvg", "Avg AQI")}{t("exposure.colPeak", "Peak")}{t("exposure.colHours", "Hours")}{t("exposure.colScore", "Total Exposure")}{t("exposure.colRisk", "Risk")}
    {d.date}{d.avgAqi}{d.maxAqi}{d.minAqi}{d.hours}{d.exposureScore}{risk.emoji} {risk.label}
    + {t("exposure.noDailyData", "No daily data available yet")} +
    {w.weekStart}{w.avgAqi}{w.maxAqi}{w.totalHours}{w.exposureScore}{risk.emoji} {risk.label}
    + {t("exposure.noWeeklyData", "No weekly data available yet")} +
    +
    +
    + + {/* Recommendations */} + {recommendations.length > 0 && ( +
    +

    ๐Ÿ’ก {t("exposure.recommendations", "Exposure Recommendations")}

    +
      + {recommendations.map((rec, idx) => ( + + ))} +
    +
    + )} + + {/* Actions */} +
    + + + +
    + + {/* Toast */} +
    + {toastMessage} +
    +
    +
    + ); +} diff --git a/src/components/ExposureTimelineTracker.module.css b/src/components/ExposureTimelineTracker.module.css new file mode 100644 index 0000000..794332f --- /dev/null +++ b/src/components/ExposureTimelineTracker.module.css @@ -0,0 +1,174 @@ +.root { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1.5rem; + max-width: 1100px; + margin: 0 auto; +} + +.header { text-align: center; } +.headerTitle { font-size: 1.6rem; font-weight: 700; margin: 0 0 0.25rem; color: var(--text-primary, #0f172a); } +.headerSubtitle { font-size: 0.95rem; color: var(--text-secondary, #64748b); margin: 0; } + +/* Health score ring */ +.healthScoreSection { + display: flex; + justify-content: center; + padding: 1.5rem; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.75rem; +} + +.scoreRing { + position: relative; + width: 160px; + height: 160px; + display: flex; + align-items: center; + justify-content: center; +} + +.scoreRingSvg { + position: absolute; + inset: 0; + transform: rotate(-90deg); +} + +.scoreRingBg { fill: none; stroke: var(--border-color, #e2e8f0); stroke-width: 10; } +.scoreRingFill { fill: none; stroke-width: 10; stroke-linecap: round; transition: stroke-dashoffset 0.8s ease; } + +.scoreCenter { + text-align: center; + z-index: 1; +} + +.scoreValue { font-size: 2.2rem; font-weight: 700; line-height: 1; } +.scoreLabel { font-size: 0.8rem; color: var(--text-secondary, #64748b); margin-top: 0.15rem; } + +/* Stats row */ +.statsRow { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.75rem; +} + +.statCard { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.85rem 0.5rem; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.65rem; + text-align: center; + transition: transform 0.12s, box-shadow 0.12s; +} + +.statCard:hover { transform: translateY(-2px); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.06); } +.statIcon { font-size: 1.3rem; margin-bottom: 0.2rem; } +.statValue { font-size: 1.25rem; font-weight: 700; color: var(--text-primary, #0f172a); } +.statLabel { font-size: 0.7rem; color: var(--text-secondary, #64748b); margin-top: 0.15rem; } + +/* Section card */ +.section { + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e2e8f0); + border-radius: 0.75rem; + padding: 1.25rem; +} + +.sectionTitle { + font-size: 1.05rem; + font-weight: 600; + margin: 0 0 0.75rem; + color: var(--text-primary, #0f172a); + display: flex; + align-items: center; + gap: 0.45rem; +} + +/* Daily table */ +.tableScroll { overflow-x: auto; border-radius: 0.5rem; border: 1px solid var(--border-color, #e2e8f0); } +.dataTable { width: 100%; border-collapse: collapse; font-size: 0.78rem; white-space: nowrap; } +.dataTable th, .dataTable td { padding: 0.45rem 0.65rem; border-bottom: 1px solid var(--border-color, #f1f5f9); text-align: left; } +.dataTable th { background: var(--bg-secondary, #f8fafc); font-weight: 600; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-secondary, #475569); position: sticky; top: 0; } +.dataTable tbody tr:hover { background: var(--bg-secondary, #f8fafc); } + +.riskLow { color: #22c55e; font-weight: 600; } +.riskModerate { color: #eab308; font-weight: 600; } +.riskHigh { color: #f97316; font-weight: 600; } +.riskCritical { color: #ef4444; font-weight: 600; } + +/* Recommendations */ +.recList { display: flex; flex-direction: column; gap: 0.6rem; list-style: none; padding: 0; margin: 0; } +.recItem { + display: flex; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + border-left: 4px solid var(--border-color, #cbd5e1); + background: var(--bg-secondary, #f8fafc); +} +.recHigh { border-left-color: #ef4444; background: #fef2f2; } +.recMedium { border-left-color: #f59e0b; background: #fffbeb; } +.recLow { border-left-color: #22c55e; background: #f0fdf4; } +.recInfo { border-left-color: #3b82f6; background: #eff6ff; } + +.recIcon { font-size: 1.3rem; flex-shrink: 0; } +.recContent { flex: 1; } +.recTitle { font-size: 0.9rem; font-weight: 600; margin: 0 0 0.2rem; color: var(--text-primary, #0f172a); } +.recDesc { font-size: 0.8rem; margin: 0; color: var(--text-secondary, #475569); line-height: 1.5; } + +/* Actions */ +.actionsBar { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: center; } + +.actionBtn { + padding: 0.55rem 1.25rem; + border: 1px solid var(--border-color, #cbd5e1); + border-radius: 0.5rem; + background: var(--bg-card, #fff); + color: var(--text-primary, #0f172a); + font-weight: 600; + font-size: 0.85rem; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +.actionBtn:hover { background: var(--bg-secondary, #f8fafc); border-color: var(--brand, #0d9488); } +.actionBtnDanger { border-color: #ef4444; color: #ef4444; } +.actionBtnDanger:hover { background: #fef2f2; } + +/* Toast */ +.toast { + position: fixed; bottom: 1.5rem; left: 50%; + transform: translateX(-50%) translateY(120%); + background: #1e293b; color: #fff; + padding: 0.65rem 1.25rem; border-radius: 0.5rem; + font-size: 0.85rem; font-weight: 500; z-index: 9999; + opacity: 0; transition: transform 0.25s ease, opacity 0.25s ease; + pointer-events: none; +} +.toastVisible { transform: translateX(-50%) translateY(0); opacity: 1; } + +/* Empty state */ +.emptyState { text-align: center; padding: 3rem 1.5rem; color: var(--text-secondary, #94a3b8); } +.emptyIcon { font-size: 2.5rem; margin-bottom: 0.75rem; } +.emptyTitle { font-size: 1.1rem; font-weight: 600; margin: 0 0 0.3rem; color: var(--text-primary, #475569); } +.emptyDesc { font-size: 0.85rem; margin: 0; max-width: 400px; margin-left: auto; margin-right: auto; line-height: 1.5; } + +/* Tabs */ +.tabBar { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; } +.tabBtn { + padding: 0.4rem 1rem; + border: 1px solid var(--border-color, #cbd5e1); + border-radius: 999px; + background: var(--bg-card, #fff); + color: var(--text-secondary, #475569); + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all 0.12s; +} +.tabBtn:hover { border-color: var(--brand, #0d9488); color: var(--brand, #0d9488); } +.tabBtnActive { background: var(--brand, #0d9488); color: #fff; border-color: var(--brand, #0d9488); } diff --git a/src/components/ExposureTimelineTracker.test.jsx b/src/components/ExposureTimelineTracker.test.jsx new file mode 100644 index 0000000..02ae50a --- /dev/null +++ b/src/components/ExposureTimelineTracker.test.jsx @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ExposureTimelineTracker from "./ExposureTimelineTracker"; +import { + readExposureHistory, + writeExposureHistory, + recordExposure, + computeDailySummaries, + computeWeeklySummaries, + computeHealthScore, + generateRecommendations, + getRiskMeta, + exposureToCSV, + clearExposureHistory, +} from "../services/exposureTimelineService"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key, opts) => (typeof opts === "string" ? opts : opts?.defaultValue || key), + }), +})); + +vi.mock("../services/dataExportService", () => ({ + triggerDownload: vi.fn(), + copyToClipboard: vi.fn(async () => true), +})); + +// --------------------------------------------------------------------------- +// Service tests +// --------------------------------------------------------------------------- +describe("exposureTimelineService", () => { + const mockHistory = [ + { date: "2026-08-25", hour: 8, aqi: 80, city: "Delhi", timestamp: 1724568000000 }, + { date: "2026-08-25", hour: 12, aqi: 120, city: "Delhi", timestamp: 1724582400000 }, + { date: "2026-08-25", hour: 18, aqi: 95, city: "Delhi", timestamp: 1724600800000 }, + { date: "2026-08-26", hour: 8, aqi: 110, city: "Delhi", timestamp: 1724654400000 }, + { date: "2026-08-26", hour: 14, aqi: 140, city: "Delhi", timestamp: 1724676000000 }, + { date: "2026-08-27", hour: 10, aqi: 60, city: "Delhi", timestamp: 1724748000000 }, + ]; + + describe("computeDailySummaries", () => { + it("groups by date and computes averages", () => { + const summaries = computeDailySummaries(mockHistory); + expect(summaries.length).toBe(3); + const aug25 = summaries.find((s) => s.date === "2026-08-25"); + expect(aug25.hours).toBe(3); + expect(aug25.avgAqi).toBeCloseTo(98.33, 0); + expect(aug25.maxAqi).toBe(120); + expect(aug25.minAqi).toBe(80); + }); + + it("returns empty for empty input", () => { + expect(computeDailySummaries([])).toEqual([]); + }); + + it("handles null input", () => { + expect(computeDailySummaries(null)).toEqual([]); + }); + }); + + describe("computeWeeklySummaries", () => { + it("groups by week and computes totals", () => { + const summaries = computeWeeklySummaries(mockHistory); + expect(summaries.length).toBeGreaterThanOrEqual(1); + expect(summaries[0].totalHours).toBeGreaterThan(0); + expect(typeof summaries[0].riskLevel).toBe("string"); + }); + }); + + describe("computeHealthScore", () => { + it("returns 100 for empty history", () => { + const result = computeHealthScore([]); + expect(result.score).toBe(100); + }); + + it("returns lower score for high AQI history", () => { + const highAqiHistory = Array.from({ length: 14 }, (_, i) => ({ + date: `2026-08-${String(i + 1).padStart(2, "0")}`, + hour: 10, + aqi: 180, + city: "Delhi", + })); + const result = computeHealthScore(highAqiHistory); + expect(result.score).toBeLessThan(50); + }); + + it("returns high score for low AQI history", () => { + const lowAqiHistory = Array.from({ length: 14 }, (_, i) => ({ + date: `2026-08-${String(i + 1).padStart(2, "0")}`, + hour: 10, + aqi: 30, + city: "Delhi", + })); + const result = computeHealthScore(lowAqiHistory); + expect(result.score).toBeGreaterThanOrEqual(80); + }); + }); + + describe("generateRecommendations", () => { + it("returns recommendations array", () => { + const recs = generateRecommendations(mockHistory); + expect(Array.isArray(recs)).toBe(true); + expect(recs.length).toBeGreaterThan(0); + expect(recs[0]).toHaveProperty("title"); + expect(recs[0]).toHaveProperty("description"); + expect(recs[0]).toHaveProperty("priority"); + expect(recs[0]).toHaveProperty("icon"); + }); + + it("suggests building profile for few data points", () => { + const recs = generateRecommendations([{ date: "2026-08-28", hour: 10, aqi: 50, city: "A" }]); + expect(recs.some((r) => r.title.includes("Build"))).toBe(true); + }); + + it("flags consecutive high exposure days", () => { + const highDays = []; + for (let d = 25; d <= 28; d++) { + for (let h = 8; h <= 20; h += 4) { + highDays.push({ date: `2026-08-${d}`, hour: h, aqi: 170, city: "Delhi" }); + } + } + const recs = generateRecommendations(highDays); + expect(recs.some((r) => r.title.includes("Extended"))).toBe(true); + }); + }); + + describe("getRiskMeta", () => { + it("returns metadata for all risk levels", () => { + expect(getRiskMeta("low").emoji).toBe("๐ŸŸข"); + expect(getRiskMeta("moderate").emoji).toBe("๐ŸŸก"); + expect(getRiskMeta("high").emoji).toBe("๐ŸŸ "); + expect(getRiskMeta("critical").emoji).toBe("๐Ÿ”ด"); + expect(getRiskMeta("unknown").emoji).toBe("โšช"); + }); + }); + + describe("exposureToCSV", () => { + it("produces valid CSV", () => { + const csv = exposureToCSV(mockHistory); + expect(csv).toContain("Date,Hour,AQI,City,Timestamp"); + expect(csv).toContain("Delhi"); + expect(csv.split("\n").length).toBe(mockHistory.length + 2); // header comment + header + rows + }); + + it("handles empty input", () => { + const csv = exposureToCSV([]); + expect(csv).toContain("Date,Hour,AQI,City,Timestamp"); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Component tests +// --------------------------------------------------------------------------- +describe("ExposureTimelineTracker", () => { + it("renders panel with title", () => { + render(); + expect(screen.getByTestId("exposure-timeline-tracker")).toBeTruthy(); + expect(screen.getByText(/Exposure Timeline/)).toBeTruthy(); + }); + + it("displays health score section", () => { + render(); + expect(screen.getByTestId("health-score")).toBeTruthy(); + }); + + it("displays stats row", () => { + render(); + expect(screen.getByTestId("stats-row")).toBeTruthy(); + }); + + it("displays action buttons", () => { + render(); + expect(screen.getByTestId("export-btn")).toBeTruthy(); + expect(screen.getByTestId("copy-btn")).toBeTruthy(); + expect(screen.getByTestId("clear-btn")).toBeTruthy(); + }); + + it("shows empty state when no data", () => { + // Mock localStorage to return empty + const orig = window.localStorage.getItem; + window.localStorage.getItem = () => null; + render(); + expect(screen.getByText(/No exposure data yet/)).toBeTruthy(); + window.localStorage.getItem = orig; + }); +}); diff --git a/src/services/alertRulesService.js b/src/services/alertRulesService.js new file mode 100644 index 0000000..d1053e5 --- /dev/null +++ b/src/services/alertRulesService.js @@ -0,0 +1,358 @@ +/** + * Alert Rules Engine Service + * + * Manages user-defined alert rules that evaluate incoming AQI data against + * custom thresholds and conditions. Rules are persisted in localStorage. + * + * Supports: + * - AQI threshold alerts (above/below/between) + * - Pollutant-specific alerts (PM2.5, PM10, NOโ‚‚, Oโ‚ƒ, CO) + * - Time-of-day conditions (morning/afternoon/evening/night or custom hours) + * - Frequency throttling (once per N hours) + * - Enable/disable individual rules + */ + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const RULES_STORAGE_KEY = 'pch_alert_rules'; +const RULE_HISTORY_KEY = 'pch_alert_history'; + +export const POLLUTANT_OPTIONS = [ + { key: 'us_aqi', label: 'US AQI', unit: '' }, + { key: 'pm2_5', label: 'PM2.5', unit: 'ยตg/mยณ' }, + { key: 'pm10', label: 'PM10', unit: 'ยตg/mยณ' }, + { key: 'nitrogen_dioxide', label: 'NOโ‚‚', unit: 'ยตg/mยณ' }, + { key: 'ozone', label: 'Oโ‚ƒ', unit: 'ยตg/mยณ' }, + { key: 'carbon_monoxide', label: 'CO', unit: 'mg/mยณ' }, +]; + +export const OPERATORS = [ + { key: 'above', label: 'Above (โ‰ฅ)', symbol: 'โ‰ฅ' }, + { key: 'below', label: 'Below (โ‰ค)', symbol: 'โ‰ค' }, + { key: 'equals', label: 'Equals (=)', symbol: '=' }, +]; + +export const TIME_WINDOWS = [ + { key: 'any', label: 'Any Time', hours: null }, + { key: 'morning', label: 'Morning (6โ€“12)', hours: [6, 7, 8, 9, 10, 11] }, + { key: 'afternoon', label: 'Afternoon (12โ€“18)', hours: [12, 13, 14, 15, 16, 17] }, + { key: 'evening', label: 'Evening (18โ€“24)', hours: [18, 19, 20, 21, 22, 23] }, + { key: 'night', label: 'Night (0โ€“6)', hours: [0, 1, 2, 3, 4, 5] }, +]; + +export const THROTTLE_OPTIONS = [ + { key: 1, label: 'Every time' }, + { key: 3, label: 'Every 3 hours' }, + { key: 6, label: 'Every 6 hours' }, + { key: 12, label: 'Every 12 hours' }, + { key: 24, label: 'Once per day' }, +]; + +// --------------------------------------------------------------------------- +// Rule structure +// --------------------------------------------------------------------------- + +/** + * @typedef {Object} AlertRule + * @property {string} id - UUID + * @property {string} name - User-friendly label + * @property {string} pollutant - Key from POLLUTANT_OPTIONS + * @property {string} operator - 'above' | 'below' | 'equals' + * @property {number} threshold - Numeric threshold value + * @property {string} timeWindow - Key from TIME_WINDOWS + * @property {number} throttleHours - Minimum hours between firings + * @property {boolean} enabled - Whether the rule is active + * @property {string} severity - 'info' | 'warning' | 'critical' + * @property {number} createdAt - Timestamp + */ + +// --------------------------------------------------------------------------- +// CRUD operations +// --------------------------------------------------------------------------- + +/** + * @returns {AlertRule[]} + */ +export function readRules() { + try { + if (typeof window === 'undefined') return []; + const raw = window.localStorage.getItem(RULES_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * @param {AlertRule[]} rules + */ +export function writeRules(rules) { + try { + if (typeof window !== 'undefined') { + window.localStorage.setItem(RULES_STORAGE_KEY, JSON.stringify(rules)); + } + } catch { + // Best effort + } +} + +/** + * Creates a new rule with a generated ID. + * + * @param {Partial} partial + * @returns {AlertRule} + */ +export function createRule(partial = {}) { + const rule = { + id: `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + name: partial.name || 'New Alert Rule', + pollutant: partial.pollutant || 'us_aqi', + operator: partial.operator || 'above', + threshold: partial.threshold ?? 100, + timeWindow: partial.timeWindow || 'any', + throttleHours: partial.throttleHours ?? 6, + enabled: partial.enabled !== false, + severity: partial.severity || 'warning', + createdAt: Date.now(), + }; + const rules = readRules(); + rules.push(rule); + writeRules(rules); + return rule; +} + +/** + * Updates an existing rule by ID. + * + * @param {string} id + * @param {Partial} updates + * @returns {AlertRule|null} + */ +export function updateRule(id, updates) { + const rules = readRules(); + const idx = rules.findIndex((r) => r.id === id); + if (idx < 0) return null; + rules[idx] = { ...rules[idx], ...updates }; + writeRules(rules); + return rules[idx]; +} + +/** + * Deletes a rule by ID. + * + * @param {string} id + * @returns {boolean} + */ +export function deleteRule(id) { + const rules = readRules(); + const filtered = rules.filter((r) => r.id !== id); + if (filtered.length === rules.length) return false; + writeRules(filtered); + return true; +} + +/** + * Toggles a rule's enabled state. + * + * @param {string} id + * @returns {AlertRule|null} + */ +export function toggleRule(id) { + const rules = readRules(); + const rule = rules.find((r) => r.id === id); + if (!rule) return null; + rule.enabled = !rule.enabled; + writeRules(rules); + return rule; +} + +// --------------------------------------------------------------------------- +// Evaluation engine +// --------------------------------------------------------------------------- + +/** + * Evaluates all active rules against a set of current readings. + * + * @param {Object} current - Current readings { us_aqi, pm2_5, pm10, nitrogen_dioxide, ozone, carbon_monoxide } + * @returns {{ triggered: AlertRule[], firedIds: string[] }} + */ +export function evaluateRules(current) { + if (!current) return { triggered: [], firedIds: [] }; + + const rules = readRules(); + const history = readAlertHistory(); + const now = Date.now(); + const currentHour = new Date().getHours(); + + const triggered = []; + const firedIds = []; + + for (const rule of rules) { + if (!rule.enabled) continue; + + // Time window check + const window = TIME_WINDOWS.find((tw) => tw.key === rule.timeWindow); + if (window?.hours && !window.hours.includes(currentHour)) continue; + + // Read value + const value = current[rule.pollutant]; + if (value == null || !Number.isFinite(value)) continue; + + // Operator check + let matched = false; + switch (rule.operator) { + case 'above': + matched = value >= rule.threshold; + break; + case 'below': + matched = value <= rule.threshold; + break; + case 'equals': + matched = Math.abs(value - rule.threshold) < 0.5; + break; + default: + matched = false; + } + + if (!matched) continue; + + // Throttle check + const lastFired = history[rule.id]; + if (lastFired) { + const elapsedHours = (now - lastFired) / (1000 * 60 * 60); + if (elapsedHours < rule.throttleHours) continue; + } + + triggered.push(rule); + firedIds.push(rule.id); + } + + // Update history for fired rules + if (firedIds.length > 0) { + const updatedHistory = { ...history }; + for (const id of firedIds) { + updatedHistory[id] = now; + } + writeAlertHistory(updatedHistory); + } + + return { triggered, firedIds }; +} + +// --------------------------------------------------------------------------- +// Alert history (for throttling) +// --------------------------------------------------------------------------- + +function readAlertHistory() { + try { + if (typeof window === 'undefined') return {}; + const raw = window.localStorage.getItem(RULE_HISTORY_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +} + +function writeAlertHistory(history) { + try { + if (typeof window !== 'undefined') { + window.localStorage.setItem(RULE_HISTORY_KEY, JSON.stringify(history)); + } + } catch { + // Best effort + } +} + +// --------------------------------------------------------------------------- +// Notification helpers +// --------------------------------------------------------------------------- + +/** + * Sends a browser notification for a triggered rule. + * + * @param {AlertRule} rule + * @param {Object} current + * @returns {boolean} Whether notification was shown + */ +export function sendNotification(rule, current) { + if (typeof window === 'undefined' || !window.Notification) return false; + if (window.Notification.permission !== 'granted') return false; + + const pollutant = POLLUTANT_OPTIONS.find((p) => p.key === rule.pollutant); + const value = current[rule.pollutant]; + const symbol = OPERATORS.find((o) => o.key === rule.operator)?.symbol || 'โ‰ฅ'; + + const body = `${pollutant?.label || rule.pollutant}: ${value} ${pollutant?.unit || ''} ${symbol} ${rule.threshold}`; + const icon = rule.severity === 'critical' ? '๐Ÿ”ด' : rule.severity === 'warning' ? '๐ŸŸ ' : '๐Ÿ”ต'; + + try { + new window.Notification(`${icon} ${rule.name}`, { + body, + icon: '/favicon.ico', + tag: rule.id, + }); + return true; + } catch { + return false; + } +} + +/** + * Requests browser notification permission. + * + * @returns {Promise} Permission state + */ +export async function requestNotificationPermission() { + if (typeof window === 'undefined' || !window.Notification) return 'unavailable'; + if (window.Notification.permission === 'granted') return 'granted'; + if (window.Notification.permission === 'denied') return 'denied'; + const result = await window.Notification.requestPermission(); + return result; +} + +// --------------------------------------------------------------------------- +// Preset rules +// --------------------------------------------------------------------------- + +export const PRESET_RULES = [ + { + name: 'High AQI Alert', + pollutant: 'us_aqi', + operator: 'above', + threshold: 150, + timeWindow: 'any', + throttleHours: 6, + severity: 'critical', + }, + { + name: 'PM2.5 Warning', + pollutant: 'pm2_5', + operator: 'above', + threshold: 55, + timeWindow: 'any', + throttleHours: 12, + severity: 'warning', + }, + { + name: 'Morning Air Check', + pollutant: 'us_aqi', + operator: 'above', + threshold: 100, + timeWindow: 'morning', + throttleHours: 24, + severity: 'warning', + }, + { + name: 'Good Air Window', + pollutant: 'us_aqi', + operator: 'below', + threshold: 50, + timeWindow: 'morning', + throttleHours: 6, + severity: 'info', + }, +]; diff --git a/src/services/apiClient.js b/src/services/apiClient.js new file mode 100644 index 0000000..e00f6c8 --- /dev/null +++ b/src/services/apiClient.js @@ -0,0 +1,64 @@ +const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; + +export async function apiClient(pathSegments, options = {}) { + const { params = {}, defaultError = 'Request failed', signal, method = 'GET', body } = options; + + const base = API_BASE.replace(/\/+$/, ''); + const path = Array.isArray(pathSegments) + ? pathSegments.map(segment => encodeURIComponent(segment)).join('/') + : pathSegments; + + let urlString = `${base}/${path}`; + const url = (urlString.startsWith('http') || typeof window === 'undefined') + ? new URL(urlString, 'http://localhost') + : new URL(urlString, window.location.origin); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.append(key, value); + } + }); + + let finalUrl = url.toString(); + if (finalUrl.startsWith('http://localhost') && urlString.startsWith('/')) { + finalUrl = finalUrl.replace('http://localhost', ''); + } + + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + + const token = typeof localStorage !== 'undefined' ? localStorage.getItem('token') : null; + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + const response = await fetch(finalUrl, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + signal + }); + + if (!response.ok) { + let errorMessage = defaultError; + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + try { + const errorData = await response.json(); + if (errorData && errorData.message) { + errorMessage = errorData.message; + } + } catch (e) { + // ignore + } + } + throw new Error(errorMessage); + } + + if (response.status === 204) { + return null; + } + + const text = await response.text(); + return text ? JSON.parse(text) : null; +} diff --git a/src/services/challengeService.js b/src/services/challengeService.js index 71b4ba0..354ccb2 100644 --- a/src/services/challengeService.js +++ b/src/services/challengeService.js @@ -2,68 +2,45 @@ * @fileoverview Frontend service for fetching challenges, updating user progress, and claiming rewards. */ -const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; +import { apiClient } from './apiClient'; /** * Fetches all active challenges and the current user's progress. + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise} */ -export const fetchActiveChallenges = async () => { - const response = await fetch(`${API_BASE}/challenges/active`, { +export const fetchActiveChallenges = (signal) => { + return apiClient(['challenges', 'active'], { method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, + signal, + defaultError: 'Failed to fetch active challenges.' }); - - if (!response.ok) { - throw new Error('Failed to fetch active challenges.'); - } - - return response.json(); }; /** * Joins a specific challenge for the current user. * @param {string} challengeId - The ID of the challenge to join. + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise} */ -export const joinChallenge = async (challengeId) => { - const response = await fetch(`${API_BASE}/challenges/${challengeId}/join`, { +export const joinChallenge = (challengeId, signal) => { + return apiClient(['challenges', challengeId, 'join'], { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, + signal, + defaultError: 'Failed to join challenge.' }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || 'Failed to join challenge.'); - } - - return response.json(); }; /** * Claims the reward for a completed challenge. * @param {string} challengeId - The ID of the completed challenge. + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise} */ -export const claimChallengeReward = async (challengeId) => { - const response = await fetch(`${API_BASE}/challenges/${challengeId}/claim`, { +export const claimChallengeReward = (challengeId, signal) => { + return apiClient(['challenges', challengeId, 'claim'], { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, + signal, + defaultError: 'Failed to claim reward.' }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || 'Failed to claim reward.'); - } - - return response.json(); }; diff --git a/src/services/cityComparisonReportService.js b/src/services/cityComparisonReportService.js new file mode 100644 index 0000000..54d812e --- /dev/null +++ b/src/services/cityComparisonReportService.js @@ -0,0 +1,253 @@ +/** + * City Comparison Report Service + * + * Provides utilities for comparing AQI data across multiple cities: + * ranking, differential analysis, health-risk categorisation, and + * structured report generation. + */ + +// --------------------------------------------------------------------------- +// AQI band classification +// --------------------------------------------------------------------------- + +const AQI_BANDS = [ + { min: 0, max: 50, label: 'Good', color: '#22c55e', risk: 'low' }, + { min: 51, max: 100, label: 'Moderate', color: '#eab308', risk: 'low' }, + { min: 101, max: 150, label: 'Unhealthy for Sensitive Groups', color: '#f97316', risk: 'moderate' }, + { min: 151, max: 200, label: 'Unhealthy', color: '#ef4444', risk: 'high' }, + { min: 201, max: 300, label: 'Very Unhealthy', color: '#9333ea', risk: 'very-high' }, + { min: 301, max: 500, label: 'Hazardous', color: '#7f1d1d', risk: 'extreme' }, +]; + +/** + * @param {number} aqi + * @returns {{ label: string, color: string, risk: string }} + */ +export function getAQIBand(aqi) { + if (aqi == null || !Number.isFinite(aqi)) return { label: 'Unknown', color: '#94a3b8', risk: 'unknown' }; + return AQI_BANDS.find((b) => aqi >= b.min && aqi <= b.max) || { label: 'Hazardous', color: '#7f1d1d', risk: 'extreme' }; +} + +// --------------------------------------------------------------------------- +// City ranking +// --------------------------------------------------------------------------- + +/** + * Ranks cities by their current AQI (best = lowest AQI first). + * + * @param {Array<{ name: string, aqi: number|null, pm2_5?: number|null, pm10?: number|null, no2?: number|null }>} cities + * @returns {Array} Sorted copy with rank, band info, and relativeDiff + */ +export function rankCities(cities) { + if (!Array.isArray(cities) || cities.length === 0) return []; + + const valid = cities.filter((c) => c && typeof c.aqi === 'number' && Number.isFinite(c.aqi)); + const sorted = [...valid].sort((a, b) => a.aqi - b.aqi); + + const bestAqi = sorted[0]?.aqi ?? 0; + + return sorted.map((city, idx) => { + const band = getAQIBand(city.aqi); + return { + ...city, + rank: idx + 1, + band, + relativeDiff: bestAqi > 0 ? ((city.aqi - bestAqi) / bestAqi * 100).toFixed(1) : '0.0', + }; + }); +} + +// --------------------------------------------------------------------------- +// Pairwise differential +// --------------------------------------------------------------------------- + +/** + * Computes the differential between two cities' AQI readings. + * + * @param {{ name: string, aqi: number|null }} cityA + * @param {{ name: string, aqi: number|null }} cityB + * @returns {{ diff: number, percentDiff: string, worseCity: string|null, summary: string }} + */ +export function computeDifferential(cityA, cityB) { + const aqiA = cityA?.aqi ?? null; + const aqiB = cityB?.aqi ?? null; + + if (aqiA == null && aqiB == null) { + return { diff: 0, percentDiff: '0.0', worseCity: null, summary: 'Both cities have no data.' }; + } + if (aqiA == null) { + return { diff: 0, percentDiff: '0.0', worseCity: cityB.name, summary: `${cityA.name} has no data; ${cityB.name} AQI is ${aqiB}.` }; + } + if (aqiB == null) { + return { diff: 0, percentDiff: '0.0', worseCity: cityA.name, summary: `${cityB.name} has no data; ${cityA.name} AQI is ${aqiA}.` }; + } + + const diff = aqiA - aqiB; + const pct = aqiB !== 0 ? Math.abs(diff / aqiB * 100).toFixed(1) : '0.0'; + const worseCity = diff > 0 ? cityA.name : diff < 0 ? cityB.name : null; + + let summary; + if (diff === 0) { + summary = `${cityA.name} and ${cityB.name} have identical AQI (${aqiA}).`; + } else { + const cleaner = diff < 0 ? cityA.name : cityB.name; + const dirtier = diff > 0 ? cityA.name : cityB.name; + summary = `${dirtier} has ${Math.abs(diff)} higher AQI (${pct}% worse) than ${cleaner}.`; + } + + return { diff, percentDiff: pct, worseCity, summary }; +} + +// --------------------------------------------------------------------------- +// Health risk categorisation +// --------------------------------------------------------------------------- + +/** + * Categorises a list of cities into risk groups based on AQI. + * + * @param {Array<{ name: string, aqi: number|null }>} cities + * @returns {{ safe: string[], moderate: string[], unhealthy: string[], critical: string[] }} + */ +export function categoriseByRisk(cities) { + const result = { safe: [], moderate: [], unhealthy: [], critical: [] }; + + for (const city of cities || []) { + const aqi = city?.aqi; + if (aqi == null || !Number.isFinite(aqi)) continue; + + if (aqi <= 100) result.safe.push(city.name); + else if (aqi <= 150) result.moderate.push(city.name); + else if (aqi <= 200) result.unhealthy.push(city.name); + else result.critical.push(city.name); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Pollutant breakdown comparison +// --------------------------------------------------------------------------- + +/** + * Compares pollutant levels across cities and returns a structured breakdown. + * + * @param {Array<{ name: string, pm2_5?: number|null, pm10?: number|null, no2?: number|null, o3?: number|null, co?: number|null }>} cities + * @returns {Array} Array of pollutant comparison objects + */ +export function comparePollutants(cities) { + const pollutants = [ + { key: 'pm2_5', label: 'PM2.5', unit: 'ยตg/mยณ', whoLimit: 15 }, + { key: 'pm10', label: 'PM10', unit: 'ยตg/mยณ', whoLimit: 45 }, + { key: 'no2', label: 'NOโ‚‚', unit: 'ยตg/mยณ', whoLimit: 25 }, + { key: 'o3', label: 'Oโ‚ƒ', unit: 'ยตg/mยณ', whoLimit: 100 }, + { key: 'co', label: 'CO', unit: 'mg/mยณ', whoLimit: 4 }, + ]; + + return pollutants.map((p) => { + const readings = (cities || []).map((c) => ({ + city: c.name, + value: c[p.key] ?? null, + exceedsLimit: typeof c[p.key] === 'number' && c[p.key] > p.whoLimit, + })); + + const validValues = readings.filter((r) => typeof r.value === 'number').map((r) => r.value); + const avg = validValues.length > 0 ? validValues.reduce((s, v) => s + v, 0) / validValues.length : 0; + + return { + pollutant: p.label, + key: p.key, + unit: p.unit, + whoLimit: p.whoLimit, + readings, + average: avg, + citiesExceedingLimit: readings.filter((r) => r.exceedsLimit).map((r) => r.city), + }; + }); +} + +// --------------------------------------------------------------------------- +// CSV export +// --------------------------------------------------------------------------- + +/** + * Generates a CSV string comparing multiple cities. + * + * @param {Array} rankedCities - Output of rankCities() + * @returns {string} + */ +export function comparisonToCSV(rankedCities) { + const headers = ['Rank', 'City', 'US AQI', 'AQI Band', 'Risk Level', 'PM2.5', 'PM10', 'NOโ‚‚', 'Oโ‚ƒ', 'CO', 'Diff from Best (%)']; + const rows = rankedCities.map((c) => [ + c.rank, + `"${(c.name || '').replace(/"/g, '""')}"`, + c.aqi ?? '', + `"${c.band?.label ?? ''}"`, + c.band?.risk ?? '', + c.pm2_5 ?? '', + c.pm10 ?? '', + c.no2 ?? '', + c.o3 ?? '', + c.co ?? '', + c.relativeDiff, + ]); + + const header = `# Multi-City AQI Comparison โ€” Generated ${new Date().toISOString()}`; + return `${header}\n${headers.join(',')}\n${rows.map((r) => r.join(',')).join('\n')}`; +} + +// --------------------------------------------------------------------------- +// Summary generation +// --------------------------------------------------------------------------- + +/** + * Generates a human-readable comparison summary. + * + * @param {Array} rankedCities + * @returns {string} + */ +export function generateComparisonSummary(rankedCities) { + if (!rankedCities || rankedCities.length === 0) return 'No cities to compare.'; + if (rankedCities.length === 1) return `Only one city (${rankedCities[0].name}) available for comparison.`; + + const best = rankedCities[0]; + const worst = rankedCities[rankedCities.length - 1]; + const riskGroups = categoriseByRisk(rankedCities); + + const lines = [ + 'โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', + ' MULTI-CITY AIR QUALITY COMPARISON', + ` Generated: ${new Date().toLocaleString()}`, + 'โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', + '', + ` Cities compared: ${rankedCities.length}`, + '', + ' โ”€โ”€โ”€ Rankings (Best to Worst) โ”€โ”€โ”€', + '', + ]; + + for (const city of rankedCities) { + const medal = city.rank === 1 ? '๐Ÿฅ‡' : city.rank === 2 ? '๐Ÿฅˆ' : city.rank === 3 ? '๐Ÿฅ‰' : `#${city.rank}`; + lines.push(` ${medal} ${city.name.padEnd(20)} AQI ${String(city.aqi).padStart(4)} (${city.band.label})`); + } + + lines.push(''); + lines.push(' โ”€โ”€โ”€ Risk Summary โ”€โ”€โ”€'); + if (riskGroups.safe.length) lines.push(` โœ… Safe (AQI โ‰ค 100): ${riskGroups.safe.join(', ')}`); + if (riskGroups.moderate.length) lines.push(` ๐ŸŸก Moderate (101โ€“150): ${riskGroups.moderate.join(', ')}`); + if (riskGroups.unhealthy.length) lines.push(` ๐ŸŸ  Unhealthy (151โ€“200): ${riskGroups.unhealthy.join(', ')}`); + if (riskGroups.critical.length) lines.push(` ๐Ÿ”ด Critical (201+): ${riskGroups.critical.join(', ')}`); + + lines.push(''); + lines.push(` Cleanest city: ${best.name} (AQI ${best.aqi})`); + lines.push(` Most polluted: ${worst.name} (AQI ${worst.aqi})`); + + const diff = computeDifferential(best, worst); + lines.push(` Gap: ${Math.abs(diff.diff)} AQI points (${diff.percentDiff}%)`); + + lines.push(''); + lines.push('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + lines.push(' Source: Pollution Control Hub'); + lines.push('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + + return lines.join('\n'); +} diff --git a/src/services/dataExportService.js b/src/services/dataExportService.js new file mode 100644 index 0000000..dfa813e --- /dev/null +++ b/src/services/dataExportService.js @@ -0,0 +1,335 @@ +/** + * Data Export Service + * + * Provides utilities for exporting air-quality trend data in multiple formats + * (CSV, JSON, formatted text reports), generating shareable data URLs, and + * copying structured reports to the clipboard. + * + * All functions are pure / side-effect-only โ€” no network requests. + */ + +// --------------------------------------------------------------------------- +// CSV export +// --------------------------------------------------------------------------- + +/** + * Converts an array of AQI trend data points into a CSV string. + * + * Each row includes: timestamp, US AQI, PM2.5, PM10, NOโ‚‚, Oโ‚ƒ, CO. + * Null/missing values are written as empty fields. + * + * @param {Array} trendData - Hourly AQI trend points + * @param {string} [cityName='Unknown'] - City label for the header comment + * @returns {string} Complete CSV content with header row + */ +export function trendToCSV(trendData, cityName = 'Unknown') { + const headers = [ + 'Timestamp', + 'US AQI', + 'PM2.5 (ยตg/mยณ)', + 'PM10 (ยตg/mยณ)', + 'NOโ‚‚ (ยตg/mยณ)', + 'Oโ‚ƒ (ยตg/mยณ)', + 'CO (mg/mยณ)', + ]; + + const rows = (Array.isArray(trendData) ? trendData : []).map((point) => [ + point.time ?? '', + formatNumber(point.us_aqi), + formatNumber(point.pm2_5), + formatNumber(point.pm10), + formatNumber(point.nitrogen_dioxide), + formatNumber(point.ozone), + formatNumber(point.carbon_monoxide), + ]); + + const headerComment = `# Air Quality Data Export โ€” ${cityName} โ€” Generated ${new Date().toISOString()}`; + const csvHeader = headers.join(','); + const csvRows = rows.map((r) => r.join(',')).join('\n'); + + return `${headerComment}\n${csvHeader}\n${csvRows}`; +} + +// --------------------------------------------------------------------------- +// JSON export +// --------------------------------------------------------------------------- + +/** + * Serialises trend data into a structured JSON export object. + * + * @param {Array} trendData + * @param {string} cityName + * @param {{ lat: number, lon: number }} position + * @returns {string} Pretty-printed JSON string + */ +export function trendToJSON(trendData, cityName, position) { + const payload = { + exportVersion: '1.0', + generatedAt: new Date().toISOString(), + city: cityName, + coordinates: { + latitude: position?.lat ?? null, + longitude: position?.lon ?? null, + }, + dataPoints: (Array.isArray(trendData) ? trendData : []).map((point) => ({ + timestamp: point.time ?? null, + aqi: { + us_aqi: point.us_aqi ?? null, + }, + pollutants: { + pm2_5: point.pm2_5 ?? null, + pm10: point.pm10 ?? null, + nitrogen_dioxide: point.nitrogen_dioxide ?? null, + ozone: point.ozone ?? null, + carbon_monoxide: point.carbon_monoxide ?? null, + }, + })), + metadata: { + totalDataPoints: (trendData || []).length, + exportFormat: 'json', + }, + }; + + return JSON.stringify(payload, null, 2); +} + +// --------------------------------------------------------------------------- +// Formatted text report +// --------------------------------------------------------------------------- + +/** + * Generates a human-readable text report of current AQI conditions. + * + * @param {Object} current - Current AQI readings { us_aqi, pm2_5, pm10, nitrogen_dioxide, ozone, carbon_monoxide } + * @param {string} cityName + * @param {Object} [position] + * @returns {string} + */ +export function generateTextReport(current, cityName, position) { + if (!current) return 'No data available.'; + + const now = new Date().toLocaleString(); + const aqiBand = getAQIBandLabel(current.us_aqi); + + const lines = [ + 'โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', + ` AIR QUALITY REPORT โ€” ${cityName.toUpperCase()}`, + ` Generated: ${now}`, + 'โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•', + '', + ` US AQI: ${current.us_aqi ?? 'โ€”'} (${aqiBand})`, + '', + ' Pollutant Readings:', + ` PM2.5: ${current.pm2_5 ?? 'โ€”'} ยตg/mยณ`, + ` PM10: ${current.pm10 ?? 'โ€”'} ยตg/mยณ`, + ` Nitrogen Dioxide: ${current.nitrogen_dioxide ?? 'โ€”'} ยตg/mยณ`, + ` Ozone: ${current.ozone ?? 'โ€”'} ยตg/mยณ`, + ` Carbon Monoxide: ${current.carbon_monoxide ?? 'โ€”'} mg/mยณ`, + '', + ]; + + if (position?.lat && position?.lon) { + lines.push(` Location: ${position.lat.toFixed(4)}ยฐN, ${position.lon.toFixed(4)}ยฐE`); + if (cityName) lines.push(` City: ${cityName}`); + lines.push(''); + } + + // Health guidance + lines.push(' โ”€โ”€โ”€ Health Guidance โ”€โ”€โ”€'); + if (current.us_aqi <= 50) { + lines.push(' โœ… Air quality is satisfactory. No health risk for the general public.'); + } else if (current.us_aqi <= 100) { + lines.push(' ๐ŸŸก Air quality is acceptable. Unusually sensitive individuals should'); + lines.push(' consider reducing prolonged outdoor exertion.'); + } else if (current.us_aqi <= 150) { + lines.push(' ๐ŸŸ  Members of sensitive groups may experience health effects.'); + lines.push(' The general public is less likely to be affected.'); + } else if (current.us_aqi <= 200) { + lines.push(' ๐Ÿ”ด Everyone may begin to experience health effects;'); + lines.push(' sensitive groups may experience more serious effects.'); + } else if (current.us_aqi <= 300) { + lines.push(' ๐ŸŸฃ Health alert: everyone may experience more serious health effects.'); + } else { + lines.push(' ๐ŸŸค Health warning of emergency conditions.'); + } + + lines.push(''); + lines.push('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + lines.push(' Source: Pollution Control Hub โ€” https://pollution-control-hub'); + lines.push('โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•'); + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Shareable link +// --------------------------------------------------------------------------- + +/** + * Generates a shareable URL that encodes the current city & position in the + * URL hash so the recipient's browser loads the same city. + * + * @param {string} cityName + * @param {number} lat + * @param {number} lon + * @returns {string} + */ +export function generateShareableLink(cityName, lat, lon) { + const base = typeof window !== 'undefined' ? window.location.origin + window.location.pathname : 'https://pollution-control-hub.netlify.app/'; + const params = new URLSearchParams(); + params.set('city', cityName); + params.set('lat', String(lat)); + params.set('lon', String(lon)); + return `${base}#${params.toString()}`; +} + +// --------------------------------------------------------------------------- +// Download helpers +// --------------------------------------------------------------------------- + +/** + * Triggers a browser file download with the given content. + * + * @param {string} content + * @param {string} filename + * @param {string} mimeType + */ +export function triggerDownload(content, filename, mimeType = 'text/plain') { + if (typeof document === 'undefined') return; + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +/** + * Copies text to the clipboard and returns success status. + * + * @param {string} text + * @returns {Promise} + */ +export async function copyToClipboard(text) { + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + // Fallback for older browsers + if (typeof document !== 'undefined') { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + return true; + } + return false; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Batch export (multiple trend datasets) +// --------------------------------------------------------------------------- + +/** + * Merges multiple city trend datasets into a single CSV with a "City" column. + * + * @param {Array<{ cityName: string, trend: Array }>} datasets + * @returns {string} + */ +export function batchTrendToCSV(datasets) { + const headers = [ + 'City', + 'Timestamp', + 'US AQI', + 'PM2.5 (ยตg/mยณ)', + 'PM10 (ยตg/mยณ)', + 'NOโ‚‚ (ยตg/mยณ)', + 'Oโ‚ƒ (ยตg/mยณ)', + 'CO (mg/mยณ)', + ]; + + const rows = []; + for (const { cityName, trend } of datasets) { + for (const point of trend || []) { + rows.push([ + `"${(cityName || '').replace(/"/g, '""')}"`, + point.time ?? '', + formatNumber(point.us_aqi), + formatNumber(point.pm2_5), + formatNumber(point.pm10), + formatNumber(point.nitrogen_dioxide), + formatNumber(point.ozone), + formatNumber(point.carbon_monoxide), + ]); + } + } + + const headerComment = `# Multi-City Air Quality Data Export โ€” Generated ${new Date().toISOString()}`; + return `${headerComment}\n${headers.join(',')}\n${rows.map((r) => r.join(',')).join('\n')}`; +} + +// --------------------------------------------------------------------------- +// Summary statistics +// --------------------------------------------------------------------------- + +/** + * Computes summary statistics for a trend dataset. + * + * @param {Array} trendData + * @returns {{ count: number, avgAqi: number, maxAqi: number, minAqi: number, avgPm25: number, avgPm10: number, avgNo2: number }} + */ +export function computeSummaryStats(trendData) { + const data = Array.isArray(trendData) ? trendData : []; + if (data.length === 0) { + return { count: 0, avgAqi: 0, maxAqi: 0, minAqi: 0, avgPm25: 0, avgPm10: 0, avgNo2: 0 }; + } + + const aqiValues = data.map((d) => d.us_aqi).filter((v) => typeof v === 'number' && Number.isFinite(v)); + const pm25Values = data.map((d) => d.pm2_5).filter((v) => typeof v === 'number' && Number.isFinite(v)); + const pm10Values = data.map((d) => d.pm10).filter((v) => typeof v === 'number' && Number.isFinite(v)); + const no2Values = data.map((d) => d.nitrogen_dioxide).filter((v) => typeof v === 'number' && Number.isFinite(v)); + + return { + count: data.length, + avgAqi: avg(aqiValues), + maxAqi: aqiValues.length > 0 ? Math.max(...aqiValues) : 0, + minAqi: aqiValues.length > 0 ? Math.min(...aqiValues) : 0, + avgPm25: avg(pm25Values), + avgPm10: avg(pm10Values), + avgNo2: avg(no2Values), + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function formatNumber(value) { + return typeof value === 'number' && Number.isFinite(value) ? String(value) : ''; +} + +function avg(values) { + if (values.length === 0) return 0; + return values.reduce((s, v) => s + v, 0) / values.length; +} + +function getAQIBandLabel(aqi) { + if (aqi == null) return 'Unknown'; + if (aqi <= 50) return 'Good'; + if (aqi <= 100) return 'Moderate'; + if (aqi <= 150) return 'Unhealthy for Sensitive Groups'; + if (aqi <= 200) return 'Unhealthy'; + if (aqi <= 300) return 'Very Unhealthy'; + return 'Hazardous'; +} diff --git a/src/services/exposureTimelineService.js b/src/services/exposureTimelineService.js new file mode 100644 index 0000000..f8cbc35 --- /dev/null +++ b/src/services/exposureTimelineService.js @@ -0,0 +1,400 @@ +/** + * Exposure Timeline Service + * + * Tracks cumulative pollution exposure, computes health-risk scores, + * and generates personalised exposure history and recommendations. + * + * All exposure data is persisted in localStorage under a single key. + */ + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const EXPOSURE_STORAGE_KEY = 'pch_exposure_history'; +const MAX_HISTORY_DAYS = 90; + +/** + * Health risk thresholds for cumulative AQI exposure (hourly buckets). + * These are simplified guidance based on WHO/EPA general recommendations. + */ +const EXPOSURE_THRESHOLDS = { + /** Below this, exposure is considered "safe" for the current hour. */ + safeHourly: 50, + /** Above this, a single hour is flagged as "moderate risk". */ + moderateHourly: 100, + /** Above this, a single hour is "high risk". */ + highHourly: 150, + /** Cumulative daily exposure score thresholds. */ + dailyLow: 50, + dailyModerate: 100, + dailyHigh: 150, + dailyCritical: 200, +}; + +/** + * Weekly exposure score thresholds for cumulative risk. + */ +const WEEKLY_THRESHOLDS = { + low: 350, // ~50 avg over 7 days + moderate: 700, // ~100 avg over 7 days + high: 1050, // ~150 avg over 7 days + critical: 1400,// ~200 avg over 7 days +}; + +// --------------------------------------------------------------------------- +// Storage helpers +// --------------------------------------------------------------------------- + +/** + * Reads the full exposure history from localStorage. + * + * @returns {Array<{ date: string, hour: number, aqi: number, city: string }>} + */ +export function readExposureHistory() { + try { + if (typeof window === 'undefined') return []; + const raw = window.localStorage.getItem(EXPOSURE_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * Persists the exposure history to localStorage. + * + * @param {Array} history + */ +export function writeExposureHistory(history) { + try { + if (typeof window === 'undefined') return; + // Trim to MAX_HISTORY_DAYS to prevent unbounded growth + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - MAX_HISTORY_DAYS); + const cutoffStr = cutoff.toISOString().slice(0, 10); + const trimmed = history.filter((entry) => entry.date >= cutoffStr); + window.localStorage.setItem(EXPOSURE_STORAGE_KEY, JSON.stringify(trimmed)); + } catch { + // Storage quota โ€” best effort + } +} + +/** + * Records a single exposure data point (called on each auto-refresh cycle). + * + * @param {number} aqi - Current US AQI reading + * @param {string} cityName + * @returns {Array} Updated history + */ +export function recordExposure(aqi, cityName) { + if (aqi == null || !Number.isFinite(aqi)) return readExposureHistory(); + + const now = new Date(); + const entry = { + date: now.toISOString().slice(0, 10), + hour: now.getHours(), + aqi: Math.round(aqi), + city: cityName || 'Unknown', + timestamp: now.getTime(), + }; + + const history = readExposureHistory(); + + // Deduplicate: replace if same date+hour+city already exists + const idx = history.findIndex( + (h) => h.date === entry.date && h.hour === entry.hour && h.city === entry.city, + ); + if (idx >= 0) { + history[idx] = entry; + } else { + history.push(entry); + } + + writeExposureHistory(history); + return history; +} + +// --------------------------------------------------------------------------- +// Aggregation +// --------------------------------------------------------------------------- + +/** + * Groups exposure records by date and computes daily summaries. + * + * @param {Array} history + * @returns {Array<{ date: string, avgAqi: number, maxAqi: number, minAqi: number, hours: number, riskLevel: string, exposureScore: number }>} + */ +export function computeDailySummaries(history) { + const byDate = new Map(); + + for (const entry of history) { + if (!entry.date || typeof entry.aqi !== 'number') continue; + if (!byDate.has(entry.date)) byDate.set(entry.date, []); + byDate.get(entry.date).push(entry.aqi); + } + + return Array.from(byDate.entries()) + .map(([date, aqis]) => { + const avg = aqis.reduce((s, v) => s + v, 0) / aqis.length; + const max = Math.max(...aqis); + const min = Math.min(...aqis); + const exposureScore = Math.round(avg * aqis.length); // total exposure "dose" + return { + date, + avgAqi: Math.round(avg * 10) / 10, + maxAqi: max, + minAqi: min, + hours: aqis.length, + exposureScore, + riskLevel: classifyDailyRisk(avg), + }; + }) + .sort((a, b) => a.date.localeCompare(b.date)); +} + +/** + * Groups exposure records by week and computes weekly summaries. + * + * @param {Array} history + * @returns {Array<{ weekStart: string, avgAqi: number, maxAqi: number, totalHours: number, exposureScore: number, riskLevel: string }>} + */ +export function computeWeeklySummaries(history) { + const byWeek = new Map(); + + for (const entry of history) { + if (!entry.date || typeof entry.aqi !== 'number') continue; + const d = new Date(entry.date); + // Get Monday of the week + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + const monday = new Date(d); + monday.setDate(diff); + const weekKey = monday.toISOString().slice(0, 10); + + if (!byWeek.has(weekKey)) byWeek.set(weekKey, []); + byWeek.get(weekKey).push(entry.aqi); + } + + return Array.from(byWeek.entries()) + .map(([weekStart, aqis]) => { + const avg = aqis.reduce((s, v) => s + v, 0) / aqis.length; + const max = Math.max(...aqis); + const totalScore = aqis.reduce((s, v) => s + v, 0); + return { + weekStart, + avgAqi: Math.round(avg * 10) / 10, + maxAqi: max, + totalHours: aqis.length, + exposureScore: totalScore, + riskLevel: classifyWeeklyRisk(totalScore), + }; + }) + .sort((a, b) => a.weekStart.localeCompare(b.weekStart)); +} + +// --------------------------------------------------------------------------- +// Risk classification +// --------------------------------------------------------------------------- + +/** + * @param {number} avgDailyAqi + * @returns {string} + */ +function classifyDailyRisk(avgDailyAqi) { + if (avgDailyAqi <= EXPOSURE_THRESHOLDS.dailyLow) return 'low'; + if (avgDailyAqi <= EXPOSURE_THRESHOLDS.dailyModerate) return 'moderate'; + if (avgDailyAqi <= EXPOSURE_THRESHOLDS.dailyHigh) return 'high'; + return 'critical'; +} + +/** + * @param {number} weeklyExposureScore + * @returns {string} + */ +function classifyWeeklyRisk(weeklyExposureScore) { + if (weeklyExposureScore <= WEEKLY_THRESHOLDS.low) return 'low'; + if (weeklyExposureScore <= WEEKLY_THRESHOLDS.moderate) return 'moderate'; + if (weeklyExposureScore <= WEEKLY_THRESHOLDS.high) return 'high'; + return 'critical'; +} + +/** + * Returns human-readable risk metadata for a risk level. + * + * @param {string} level + * @returns {{ label: string, color: string, emoji: string, description: string }} + */ +export function getRiskMeta(level) { + switch (level) { + case 'low': + return { label: 'Low Risk', color: '#22c55e', emoji: '๐ŸŸข', description: 'Air quality exposure is within safe limits. No immediate health concerns.' }; + case 'moderate': + return { label: 'Moderate Risk', color: '#eab308', emoji: '๐ŸŸก', description: 'Some exposure above recommended levels. Sensitive individuals should take precautions.' }; + case 'high': + return { label: 'High Risk', color: '#f97316', emoji: '๐ŸŸ ', description: 'Significant pollution exposure detected. Consider reducing outdoor activities.' }; + case 'critical': + return { label: 'Critical Risk', color: '#ef4444', emoji: '๐Ÿ”ด', description: 'Dangerous exposure levels. Avoid outdoor exertion and use air filtration indoors.' }; + default: + return { label: 'Unknown', color: '#94a3b8', emoji: 'โšช', description: 'Insufficient data to assess risk.' }; + } +} + +// --------------------------------------------------------------------------- +// Health score +// --------------------------------------------------------------------------- + +/** + * Computes a 0โ€“100 "health score" where 100 = no exposure risk. + * Based on the rolling 7-day average AQI. + * + * @param {Array} history + * @returns {{ score: number, label: string, color: string }} + */ +export function computeHealthScore(history) { + const daily = computeDailySummaries(history); + const last7 = daily.slice(-7); + + if (last7.length === 0) { + return { score: 100, label: 'No Data', color: '#94a3b8' }; + } + + const avgAqi = last7.reduce((s, d) => s + d.avgAqi, 0) / last7.length; + + // Score: 100 at AQI 0, linearly decreasing to 0 at AQI 200+ + const score = Math.max(0, Math.min(100, Math.round(100 - (avgAqi / 200) * 100))); + + let label, color; + if (score >= 80) { label = 'Excellent'; color = '#22c55e'; } + else if (score >= 60) { label = 'Good'; color = '#84cc16'; } + else if (score >= 40) { label = 'Fair'; color = '#eab308'; } + else if (score >= 20) { label = 'Poor'; color = '#f97316'; } + else { label = 'Critical'; color = '#ef4444'; } + + return { score, label, color }; +} + +// --------------------------------------------------------------------------- +// Recommendations +// --------------------------------------------------------------------------- + +/** + * Generates personalised recommendations based on exposure history. + * + * @param {Array} history + * @returns {Array<{ title: string, description: string, priority: string, icon: string }>} + */ +export function generateRecommendations(history) { + const daily = computeDailySummaries(history); + const weekly = computeWeeklySummaries(history); + const recommendations = []; + + // Check for consecutive high-exposure days + const recentDays = daily.slice(-3); + const consecutiveHigh = recentDays.filter((d) => d.riskLevel === 'high' || d.riskLevel === 'critical').length; + + if (consecutiveHigh >= 3) { + recommendations.push({ + title: 'Extended High Exposure Detected', + description: 'You have experienced 3+ consecutive days of high pollution exposure. Consider wearing N95 masks outdoors and using air purifiers indoors.', + priority: 'high', + icon: 'โš ๏ธ', + }); + } + + // Check for peak hours pattern + const hourlyAvg = new Map(); + for (const entry of history) { + if (typeof entry.hour !== 'number' || typeof entry.aqi !== 'number') continue; + if (!hourlyAvg.has(entry.hour)) hourlyAvg.set(entry.hour, []); + hourlyAvg.get(entry.hour).push(entry.aqi); + } + + let peakHour = null; + let peakAvg = 0; + for (const [hour, aqis] of hourlyAvg) { + const avg = aqis.reduce((s, v) => s + v, 0) / aqis.length; + if (avg > peakAvg) { peakAvg = avg; peakHour = hour; } + } + + if (peakHour !== null && peakAvg > 100) { + recommendations.push({ + title: `Peak Pollution at ${peakHour}:00`, + description: `AQI tends to be highest around ${peakHour}:00 (avg ${Math.round(peakAvg)}). Try to schedule outdoor activities before or after this window.`, + priority: 'medium', + icon: '๐Ÿ•', + }); + } + + // Weekly trend + if (weekly.length >= 2) { + const thisWeek = weekly[weekly.length - 1]; + const lastWeek = weekly[weekly.length - 2]; + + if (thisWeek.avgAqi > lastWeek.avgAqi * 1.2) { + recommendations.push({ + title: 'Exposure Trending Upward', + description: `This week's average AQI (${thisWeek.avgAqi}) is higher than last week's (${lastWeek.avgAqi}). Monitor conditions closely.`, + priority: 'medium', + icon: '๐Ÿ“ˆ', + }); + } else if (thisWeek.avgAqi < lastWeek.avgAqi * 0.8) { + recommendations.push({ + title: 'Exposure Improving', + description: `Great news โ€” this week's average AQI (${thisWeek.avgAqi}) is lower than last week's (${lastWeek.avgAqi}). Keep up healthy outdoor habits.`, + priority: 'low', + icon: 'โœ…', + }); + } + } + + // General recommendation if few data points + if (history.length < 10) { + recommendations.push({ + title: 'Build Your Exposure Profile', + description: 'Keep the app running to build a more accurate exposure profile. The more data points collected, the better your personalised recommendations.', + priority: 'info', + icon: '๐Ÿ“Š', + }); + } + + return recommendations; +} + +// --------------------------------------------------------------------------- +// Clear / reset +// --------------------------------------------------------------------------- + +/** + * Clears all stored exposure history. + */ +export function clearExposureHistory() { + try { + if (typeof window !== 'undefined') { + window.localStorage.removeItem(EXPOSURE_STORAGE_KEY); + } + } catch { + // Best effort + } +} + +/** + * Exports exposure history as CSV string. + * + * @param {Array} history + * @returns {string} + */ +export function exposureToCSV(history) { + const headers = ['Date', 'Hour', 'AQI', 'City', 'Timestamp']; + const rows = (history || []).map((e) => [ + e.date ?? '', + e.hour ?? '', + e.aqi ?? '', + `"${(e.city || '').replace(/"/g, '""')}"`, + e.timestamp ?? '', + ]); + const header = `# Exposure History Export โ€” Generated ${new Date().toISOString()}`; + return `${header}\n${headers.join(',')}\n${rows.map((r) => r.join(',')).join('\n')}`; +} diff --git a/src/services/footprintPlannerService.js b/src/services/footprintPlannerService.js index 711c21d..22a96d0 100644 --- a/src/services/footprintPlannerService.js +++ b/src/services/footprintPlannerService.js @@ -2,70 +2,48 @@ * @fileoverview Frontend service for saving activity logs, fetching historical trends, and retrieving personalized plans. */ -const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; +import { apiClient } from './apiClient'; /** * Logs a new carbon-emitting activity. * @param {Object} activityData - The activity details. + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise} */ -export const logActivity = async (activityData) => { - const response = await fetch(`${API_BASE}/footprint/activities`, { +export const logActivity = (activityData, signal) => { + return apiClient(['footprint', 'activities'], { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, - body: JSON.stringify(activityData), + body: activityData, + signal, + defaultError: 'Failed to log activity.' }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || 'Failed to log activity.'); - } - - return response.json(); }; /** * Fetches the user's comprehensive footprint summary and reduction plan. + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise} */ -export const fetchFootprintSummary = async () => { - const response = await fetch(`${API_BASE}/footprint/summary`, { +export const fetchFootprintSummary = (signal) => { + return apiClient(['footprint', 'summary'], { method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, + signal, + defaultError: 'Failed to fetch footprint summary.' }); - - if (!response.ok) { - throw new Error('Failed to fetch footprint summary.'); - } - - return response.json(); }; /** * Toggles the completion status of a reduction step. * @param {string} stepId - The ID of the reduction step. * @param {boolean} isCompleted - The new completion status. + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise} */ -export const updateReductionStep = async (stepId, isCompleted) => { - const response = await fetch(`${API_BASE}/footprint/steps/${stepId}`, { +export const updateReductionStep = (stepId, isCompleted, signal) => { + return apiClient(['footprint', 'steps', stepId], { method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, - body: JSON.stringify({ isCompleted }), + body: { isCompleted }, + signal, + defaultError: 'Failed to update reduction step.' }); - - if (!response.ok) { - throw new Error('Failed to update reduction step.'); - } - - return response.json(); }; diff --git a/src/services/forecastAttributionService.js b/src/services/forecastAttributionService.js index 6acc087..19bb3e9 100644 --- a/src/services/forecastAttributionService.js +++ b/src/services/forecastAttributionService.js @@ -2,51 +2,36 @@ * @fileoverview Frontend service layer for fetching forecast data and attribution metrics. */ -const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; +import { apiClient } from './apiClient'; /** * Fetches the AI-powered AQI forecast and source attribution for a specific location. * @param {number} lat - Latitude of the location. * @param {number} lng - Longitude of the location. * @param {number} days - Number of days to forecast (default 3). + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise} */ -export const fetchAqiForecast = async (lat, lng, days = 3) => { - const response = await fetch( - `${API_BASE}/forecast/aqi?lat=${lat}&lng=${lng}&days=${days}`, - { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || 'Failed to fetch AQI forecast data.'); - } - - return response.json(); +export const fetchAqiForecast = (lat, lng, days = 3, signal) => { + return apiClient(['forecast', 'aqi'], { + method: 'GET', + params: { lat, lng, days }, + signal, + defaultError: 'Failed to fetch AQI forecast data.' + }); }; /** * Fetches historical attribution data to compare with current forecasts. * @param {string} locationId - Identifier for the location. + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise} */ -export const fetchHistoricalAttribution = async (locationId) => { - const response = await fetch(`${API_BASE}/forecast/attribution/history?locationId=${locationId}`, { +export const fetchHistoricalAttribution = (locationId, signal) => { + return apiClient(['forecast', 'attribution', 'history'], { method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, + params: { locationId }, + signal, + defaultError: 'Failed to fetch historical attribution data.' }); - - if (!response.ok) { - throw new Error('Failed to fetch historical attribution data.'); - } - - return response.json(); }; diff --git a/src/services/incidentRoutingService.js b/src/services/incidentRoutingService.js index ee2e84b..d31bfc9 100644 --- a/src/services/incidentRoutingService.js +++ b/src/services/incidentRoutingService.js @@ -2,31 +2,21 @@ * @fileoverview Frontend service for fetching routed incidents and updating their lifecycle status. */ -const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; +import { apiClient } from './apiClient'; /** * Fetches all routed incidents, optionally filtered by status or category. * @param {string} [status] - Optional status filter. + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise>} */ -export const fetchRoutedIncidents = async (status) => { - const url = status - ? `${API_BASE}/incidents/routed?status=${status}` - : `${API_BASE}/incidents/routed`; - - const response = await fetch(url, { +export const fetchRoutedIncidents = (status, signal) => { + return apiClient(['incidents', 'routed'], { method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, + params: status ? { status } : {}, + signal, + defaultError: 'Failed to fetch routed incidents.' }); - - if (!response.ok) { - throw new Error('Failed to fetch routed incidents.'); - } - - return response.json(); }; /** @@ -34,22 +24,14 @@ export const fetchRoutedIncidents = async (status) => { * @param {string} incidentId - The ID of the incident. * @param {string} status - The new status. * @param {string} notes - Verification or resolution notes. + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise} */ -export const updateIncidentStatus = async (incidentId, status, notes) => { - const response = await fetch(`${API_BASE}/incidents/${incidentId}/status`, { +export const updateIncidentStatus = (incidentId, status, notes, signal) => { + return apiClient(['incidents', incidentId, 'status'], { method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, - body: JSON.stringify({ status, notes }), + body: { status, notes }, + signal, + defaultError: 'Failed to update incident status.' }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || 'Failed to update incident status.'); - } - - return response.json(); }; diff --git a/src/services/microclimateService.js b/src/services/microclimateService.js index ba8c0df..80cb5a8 100644 --- a/src/services/microclimateService.js +++ b/src/services/microclimateService.js @@ -2,7 +2,7 @@ * @fileoverview Service layer for fetching gridded temperature, humidity, and land-cover data. */ -const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; +import { apiClient } from './apiClient'; /** * Fetches hyperlocal microclimate and UHI data for a specific bounding box. @@ -10,42 +10,29 @@ const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; * @param {number} south - Southern latitude bound. * @param {number} east - Eastern longitude bound. * @param {number} west - Western longitude bound. + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise} */ -export const fetchMicroclimateData = async (north, south, east, west) => { - const queryParams = new URLSearchParams({ north, south, east, west }); - const response = await fetch(`${API_BASE}/microclimate/grid?${queryParams}`, { +export const fetchMicroclimateData = (north, south, east, west, signal) => { + return apiClient(['microclimate', 'grid'], { method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, + params: { north, south, east, west }, + signal, + defaultError: 'Failed to fetch microclimate grid data.' }); - - if (!response.ok) { - throw new Error('Failed to fetch microclimate grid data.'); - } - - return response.json(); }; /** * Saves a microclimate zone for the current user. * @param {Object} zoneData - The zone data to save. + * @param {AbortSignal} [signal] - Optional abort signal * @returns {Promise} */ -export const saveMicroclimateZone = async (zoneData) => { - const response = await fetch(`${API_BASE}/microclimate/zones`, { +export const saveMicroclimateZone = (zoneData, signal) => { + return apiClient(['microclimate', 'zones'], { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - }, - body: JSON.stringify(zoneData), + body: zoneData, + signal, + defaultError: 'Failed to save microclimate zone.' }); - - if (!response.ok) { - throw new Error('Failed to save microclimate zone.'); - } - - return response.json(); };