From 0ebf64bfa94cea71c9a5ec861fa9dafa243f285b Mon Sep 17 00:00:00 2001 From: karan-chaos Date: Fri, 28 Aug 2026 20:33:31 +0530 Subject: [PATCH 1/3] feat(export): add Data Export & Reports dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a comprehensive data export module that lets users download AQI trend data as CSV or JSON, preview and copy formatted text reports with health guidance, generate shareable city links, and filter data by time range with an interactive preview table and trend chart. ๐Ÿค– Generated with Codebuff Co-Authored-By: Codebuff --- src/App.jsx | 20 + src/components/DataExportDashboard.jsx | 428 ++++++++++++++++++ src/components/DataExportDashboard.module.css | 367 +++++++++++++++ src/components/DataExportDashboard.test.jsx | 177 ++++++++ src/services/dataExportService.js | 335 ++++++++++++++ 5 files changed, 1327 insertions(+) create mode 100644 src/components/DataExportDashboard.jsx create mode 100644 src/components/DataExportDashboard.module.css create mode 100644 src/components/DataExportDashboard.test.jsx create mode 100644 src/services/dataExportService.js diff --git a/src/App.jsx b/src/App.jsx index 2d7e010..1f9b95a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -65,6 +65,7 @@ import NoisePollutionTracker from "./components/NoisePollutionTracker"; import OceanAcidificationMonitor from "./components/OceanAcidificationMonitor"; import HealthImpactDashboard from "./components/HealthImpactDashboard"; +import DataExportDashboard from "./components/DataExportDashboard"; const AqiMissionGame = lazy(() => import("./components/AqiMissionGame")); const HotspotScoutGame = lazy(() => import("./components/HotspotScoutGame")); @@ -363,6 +364,7 @@ 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" }, ]; const [isMenuOpen, setIsMenuOpen] = useState(false); const menuRef = useRef(null); @@ -1284,6 +1286,24 @@ function AppContent() { {activeSection === "ocean-acid" && } {activeSection === "health-impact" && } + {activeSection === "data-export" && ( +
+ +
+ )} {activeSection === "CarbonCalculator" && (
+ + {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/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'; +} From 8fe8de61fe28725bfd1ead207d7fc7b926c1d13f Mon Sep 17 00:00:00 2001 From: karan-chaos Date: Fri, 28 Aug 2026 20:39:04 +0530 Subject: [PATCH 2/3] feat(comparison): add City Comparison Report dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a multi-city AQI comparison module with ranked leaderboards, pollutant-level breakdown tables with WHO-limit exceedance flags, health-risk categorisation groups, bar chart visualisation, CSV export, and a formatted text report with clipboard copy. ๐Ÿค– Generated with Codebuff Co-Authored-By: Codebuff --- src/App.jsx | 18 + src/components/CityComparisonReport.jsx | 343 ++++++++++++++++++ .../CityComparisonReport.module.css | 261 +++++++++++++ src/components/CityComparisonReport.test.jsx | 242 ++++++++++++ src/services/cityComparisonReportService.js | 253 +++++++++++++ 5 files changed, 1117 insertions(+) create mode 100644 src/components/CityComparisonReport.jsx create mode 100644 src/components/CityComparisonReport.module.css create mode 100644 src/components/CityComparisonReport.test.jsx create mode 100644 src/services/cityComparisonReportService.js diff --git a/src/App.jsx b/src/App.jsx index 1f9b95a..fb79952 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -66,6 +66,7 @@ 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")); @@ -365,6 +366,7 @@ export function SectionNav({ activeSection, onSectionChange }) { { 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); @@ -1304,6 +1306,22 @@ function AppContent() { /> )} + {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/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'); +} From 2185f4c158eeccef6004ac99baa33860a80c66ac Mon Sep 17 00:00:00 2001 From: karan-chaos Date: Fri, 28 Aug 2026 20:43:41 +0530 Subject: [PATCH 3/3] feat(exposure): add Exposure Timeline Tracker with health scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a cumulative pollution exposure tracker that records AQI readings on each refresh cycle, computes daily/weekly summaries, calculates a rolling 7-day health score with animated ring, classifies exposure risk levels, generates personalised recommendations, and supports CSV export with clipboard copy and history reset. ๐Ÿค– Generated with Codebuff Co-Authored-By: Codebuff --- src/App.jsx | 18 + src/components/ExposureTimelineTracker.jsx | 401 ++++++++++++++++++ .../ExposureTimelineTracker.module.css | 174 ++++++++ .../ExposureTimelineTracker.test.jsx | 190 +++++++++ src/services/exposureTimelineService.js | 400 +++++++++++++++++ 5 files changed, 1183 insertions(+) create mode 100644 src/components/ExposureTimelineTracker.jsx create mode 100644 src/components/ExposureTimelineTracker.module.css create mode 100644 src/components/ExposureTimelineTracker.test.jsx create mode 100644 src/services/exposureTimelineService.js diff --git a/src/App.jsx b/src/App.jsx index fb79952..18c0af8 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -67,6 +67,7 @@ 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"; const AqiMissionGame = lazy(() => import("./components/AqiMissionGame")); const HotspotScoutGame = lazy(() => import("./components/HotspotScoutGame")); @@ -367,6 +368,7 @@ export function SectionNav({ activeSection, onSectionChange }) { { 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" }, ]; const [isMenuOpen, setIsMenuOpen] = useState(false); const menuRef = useRef(null); @@ -1322,6 +1324,22 @@ function AppContent() { /> )} + {activeSection === "exposure-timeline" && ( +
+ +
+ )} {activeSection === "CarbonCalculator" && (
+ + {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/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')}`; +}