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'; +}