diff --git a/src/App.jsx b/src/App.jsx index 2d7e010..fb79952 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -65,6 +65,8 @@ import NoisePollutionTracker from "./components/NoisePollutionTracker"; import OceanAcidificationMonitor from "./components/OceanAcidificationMonitor"; import HealthImpactDashboard from "./components/HealthImpactDashboard"; +import DataExportDashboard from "./components/DataExportDashboard"; +import CityComparisonReport from "./components/CityComparisonReport"; const AqiMissionGame = lazy(() => import("./components/AqiMissionGame")); const HotspotScoutGame = lazy(() => import("./components/HotspotScoutGame")); @@ -363,6 +365,8 @@ 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" }, ]; const [isMenuOpen, setIsMenuOpen] = useState(false); const menuRef = useRef(null); @@ -1284,6 +1288,40 @@ function AppContent() { {activeSection === "ocean-acid" && } {activeSection === "health-impact" && } + {activeSection === "data-export" && ( +
+ +
+ )} + {activeSection === "city-comparison-report" && ( +
+ +
+ )} {activeSection === "CarbonCalculator" && (
+ + {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/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'; +}