From 7391507a19dcce87a069d1b2f1d613b0cf188b83 Mon Sep 17 00:00:00 2001 From: wabinyai Date: Tue, 16 Dec 2025 21:12:35 +0300 Subject: [PATCH] report --- frontend/src/app/reports/page.tsx | 649 ++++++++++++++++++++++++++- frontend/src/services/apiService.tsx | 212 ++++++++- 2 files changed, 849 insertions(+), 12 deletions(-) diff --git a/frontend/src/app/reports/page.tsx b/frontend/src/app/reports/page.tsx index 091a533..54cac74 100644 --- a/frontend/src/app/reports/page.tsx +++ b/frontend/src/app/reports/page.tsx @@ -20,13 +20,19 @@ import { } from "lucide-react" import Navigation from "@/components/navigation/navigation" import type { ReactNode } from "react" -import { getReportData } from "@/services/apiService" +import { + getReportData, + getReportSitesCatalog, + getReportTimeSeries, + type ReportTimeSeriesPoint, + type SiteReportMetrics, +} from "@/services/apiService" import { Skeleton } from "@/ui/skeleton" import { Button } from "@/ui/button" import type { SiteData, Filters } from "@/lib/types" import { jsPDF } from "jspdf" import html2canvas from "html2canvas" -import { format } from "date-fns" +import { differenceInHours, format, parseISO, subDays, subMonths } from "date-fns" import { PM25BarChart, AQICategoryChart, @@ -48,7 +54,7 @@ const Invalid = "/images/Invalid.png" import { Switch } from "@/ui/switch" import { Label } from "@/ui/label" -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from "recharts" +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell, LineChart, Line } from "recharts" // Dynamic map components for report map preview const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false }) @@ -135,6 +141,33 @@ function ReportContent() { const [lastSelectedId, setLastSelectedId] = useState(null) const [pdfMode, setPdfMode] = useState(false) + const [reportPreset, setReportPreset] = useState<"24h" | "7d" | "1m" | "3m" | "custom">("7d") + const [reportStart, setReportStart] = useState(() => subDays(new Date(), 7)) + const [reportEnd, setReportEnd] = useState(() => new Date()) + const [customStart, setCustomStart] = useState(() => subDays(new Date(), 7)) + const [customEnd, setCustomEnd] = useState(() => new Date()) + const [customPopoverOpen, setCustomPopoverOpen] = useState(false) + const [reportRangeLoading, setReportRangeLoading] = useState(false) + const [reportRangeError, setReportRangeError] = useState(null) + + const [seriesGroupBy, setSeriesGroupBy] = useState<"site" | "city">("city") + const [showTrendChart, setShowTrendChart] = useState(false) + const [topSeriesCount, setTopSeriesCount] = useState(8) + const [timeSeriesPoints, setTimeSeriesPoints] = useState([]) + const [timeSeriesLoading, setTimeSeriesLoading] = useState(false) + const [timeSeriesError, setTimeSeriesError] = useState(null) + const [timeSeriesNotice, setTimeSeriesNotice] = useState(null) + + const selectedDevicesKey = useMemo(() => selectedDevices.slice().sort().join(","), [selectedDevices]) + const timeSeriesSiteKey = useMemo(() => { + if (selectedDevices.length) return selectedDevicesKey + return filteredData + .slice(0, 20) + .map((site) => site.site_id || site._id) + .filter(Boolean) + .join(",") + }, [filteredData, selectedDevices.length, selectedDevicesKey]) + // Helper functions for calculations and recommendations const calculateAveragePM25 = (sites: SiteData[]): number => { if (sites.length === 0) return 0 @@ -199,6 +232,263 @@ function ReportContent() { } } + const formatPresetLabel = (preset: typeof reportPreset) => { + switch (preset) { + case "24h": + return "Last 24 hours" + case "7d": + return "Last 7 days" + case "1m": + return "Last 1 month" + case "3m": + return "Last 3 months" + case "custom": + return "Custom range" + default: + return "Custom range" + } + } + + const toDateTimeLocalInputValue = (date: Date) => format(date, "yyyy-MM-dd'T'HH:mm") + + const mergeReportMetricsIntoSites = (sites: SiteData[], metrics: SiteReportMetrics[]) => { + const metricsBySiteId = new Map(metrics.map((metric) => [metric.site_id, metric])) + + return sites.map((site) => { + const siteId = site.site_id || site._id + const metric = metricsBySiteId.get(siteId) + if (!metric) return site + + return { + ...site, + time: metric.lastTime || site.time, + pm2_5: { ...site.pm2_5, value: metric.pm2_5_avg }, + aqi_category: metric.aqi_category, + aqi_color: metric.aqi_color, + } + }) + } + + const refreshReportMetrics = async (override?: { start: Date; end: Date }) => { + const start = override?.start ?? reportStart + const end = override?.end ?? reportEnd + + const siteIds = (selectedDevices.length ? selectedDevices : filteredData.map((site) => site.site_id || site._id)).filter( + Boolean, + ) + + if (siteIds.length === 0) return + + try { + setReportRangeLoading(true) + setReportRangeError(null) + + const metrics = await getReportData({ + siteIds, + startTime: start.toISOString(), + endTime: end.toISOString(), + }) + + if (!metrics) { + setReportRangeError("Failed to load report measurements for the selected period.") + return + } + + setFilteredData((prev) => mergeReportMetricsIntoSites(prev, metrics)) + + setSelectedSite((prev) => { + if (!prev) return prev + const merged = mergeReportMetricsIntoSites([prev], metrics) + return merged[0] ?? prev + }) + } catch (refreshError) { + console.error(refreshError) + setReportRangeError("Failed to load report measurements for the selected period.") + } finally { + setReportRangeLoading(false) + } + } + + const refreshTimeSeries = async (override?: { start?: Date; end?: Date; force?: boolean }) => { + if (reportPreset !== "custom" && !override?.force) return + + const start = override?.start ?? reportStart + const end = override?.end ?? reportEnd + + const siteIds = (selectedDevices.length ? selectedDevices : filteredData.map((site) => site.site_id || site._id)).filter(Boolean) + + if (siteIds.length === 0) return + + try { + setTimeSeriesLoading(true) + setTimeSeriesError(null) + setTimeSeriesNotice(null) + + const maxSites = selectedDevices.length ? 30 : 20 + const limitedSiteIds = siteIds.slice(0, maxSites) + if (siteIds.length > maxSites) { + setTimeSeriesNotice( + `Trend chart is limited to ${maxSites} sites for performance. Select specific devices to focus the chart.`, + ) + } + + const points = await getReportTimeSeries({ + siteIds: limitedSiteIds, + startTime: start.toISOString(), + endTime: end.toISOString(), + }) + + if (!points) { + setTimeSeriesError("Failed to load the PM2.5 trend for the selected period.") + setTimeSeriesPoints([]) + return + } + + setTimeSeriesPoints(points) + } catch (seriesError) { + console.error(seriesError) + setTimeSeriesError("Failed to load the PM2.5 trend for the selected period.") + setTimeSeriesPoints([]) + } finally { + setTimeSeriesLoading(false) + } + } + + const timeSeriesChart = useMemo(() => { + if (timeSeriesPoints.length === 0) { + return { data: [], keys: [], colors: new Map() } + } + + const spanHours = differenceInHours(reportEnd, reportStart) + const bucketByHour = spanHours <= 48 + + type BucketAgg = { sum: number; count: number } + type BucketRow = { label: string; values: Map } + + const buckets = new Map() + const groupCounts = new Map() + + for (const point of timeSeriesPoints) { + if (typeof point.pm2_5 !== "number" || !Number.isFinite(point.pm2_5)) continue + + const date = parseISO(point.time) + const bucketDate = bucketByHour + ? new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours()) + : new Date(date.getFullYear(), date.getMonth(), date.getDate()) + const bucketMs = bucketDate.getTime() + + const label = format(bucketDate, bucketByHour ? "MMM d, HH:mm" : "MMM d") + const groupKey = + seriesGroupBy === "city" + ? point.city || "Unknown City" + : point.siteName || `Site ${point.site_id.slice(0, 6)}` + + if (!buckets.has(bucketMs)) { + buckets.set(bucketMs, { label, values: new Map() }) + } + + const bucket = buckets.get(bucketMs)! + const agg = bucket.values.get(groupKey) ?? { sum: 0, count: 0 } + agg.sum += point.pm2_5 + agg.count += 1 + bucket.values.set(groupKey, agg) + + groupCounts.set(groupKey, (groupCounts.get(groupKey) ?? 0) + 1) + } + + const keys = Array.from(groupCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, Math.max(1, topSeriesCount)) + .map(([key]) => key) + + const palette = [ + "#2563EB", + "#DC2626", + "#16A34A", + "#7C3AED", + "#EA580C", + "#0891B2", + "#DB2777", + "#0F766E", + ] + const colors = new Map(keys.map((key, index) => [key, palette[index % palette.length]])) + + const data = Array.from(buckets.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([, bucket]) => { + const row: Record = { label: bucket.label } + for (const key of keys) { + const agg = bucket.values.get(key) + row[key] = agg ? agg.sum / agg.count : null + } + return row + }) + + return { data, keys, colors } + }, [reportEnd, reportStart, seriesGroupBy, timeSeriesPoints, topSeriesCount]) + + const timeSeriesSummary = useMemo(() => { + if (timeSeriesPoints.length === 0) return [] + + type Agg = { sum: number; count: number; min: number; max: number } + const byGroup = new Map() + + for (const point of timeSeriesPoints) { + if (typeof point.pm2_5 !== "number" || !Number.isFinite(point.pm2_5)) continue + const groupKey = + seriesGroupBy === "city" + ? point.city || "Unknown City" + : point.siteName || `Site ${point.site_id.slice(0, 6)}` + + const current = byGroup.get(groupKey) ?? { sum: 0, count: 0, min: point.pm2_5, max: point.pm2_5 } + current.sum += point.pm2_5 + current.count += 1 + current.min = Math.min(current.min, point.pm2_5) + current.max = Math.max(current.max, point.pm2_5) + byGroup.set(groupKey, current) + } + + return Array.from(byGroup.entries()) + .map(([key, agg]) => ({ + key, + avg: agg.count ? agg.sum / agg.count : null, + min: agg.min, + max: agg.max, + count: agg.count, + })) + .filter((row) => typeof row.avg === "number" && Number.isFinite(row.avg)) + .sort((a, b) => (b.avg ?? 0) - (a.avg ?? 0)) + .slice(0, 4) + }, [seriesGroupBy, timeSeriesPoints]) + + const applyPresetRange = async (preset: typeof reportPreset) => { + const now = new Date() + const start = + preset === "24h" + ? subDays(now, 1) + : preset === "7d" + ? subDays(now, 7) + : preset === "1m" + ? subMonths(now, 1) + : preset === "3m" + ? subMonths(now, 3) + : reportStart + + setReportPreset(preset) + setReportStart(start) + setReportEnd(now) + setCustomStart(start) + setCustomEnd(now) + setShowTrendChart(false) + setTimeSeriesPoints([]) + setTimeSeriesError(null) + setTimeSeriesNotice(null) + + if (showReportOnPage) { + await refreshReportMetrics({ start, end: now }) + } + } + const getConclusion = (selectedSite: SiteData | null, filters: Filters, filteredData: SiteData[]): string => { if (selectedSite) { return `In conclusion, the air quality at ${selectedSite.siteDetails.name} requires attention. Further investigation and mitigation strategies are recommended.` @@ -334,9 +624,12 @@ function ReportContent() { async function fetchData() { try { setLoading(true) - const data = await getReportData() + const data = await getReportSitesCatalog() if (data) { - const typedData = data as SiteData[] + const typedData = (data as SiteData[]).map((site) => ({ + ...site, + _id: site.site_id || site._id, + })) setSiteData(typedData) setFilteredData(typedData) @@ -440,6 +733,13 @@ function ReportContent() { } }, [filters, siteData, selectedSite]) + useEffect(() => { + if (!showReportOnPage) return + if (reportPreset !== "custom") return + if (!showTrendChart) return + void refreshTimeSeries({ force: true }) + }, [showReportOnPage, reportPreset, reportStart, reportEnd, timeSeriesSiteKey, showTrendChart]) + // Handle filter changes const handleFilterChange = (filterType: keyof Filters, values: string[]) => { setFilters((prev) => ({ @@ -992,6 +1292,136 @@ function ReportContent() { + {/* Timeline */} +
+
+
+ Timeline +
+ + + + +
+ + + + + + +
+
+ + setCustomStart(new Date(e.target.value))} + /> +
+
+ + setCustomEnd(new Date(e.target.value))} + /> +
+
+ + +
+
+
+
+ + + {format(reportStart, "MMM d, yyyy HH:mm")} – {format(reportEnd, "MMM d, yyyy HH:mm")} + + + {reportPreset === "custom" && ( +
+ Trend + + Hue by + + +
+ )} +
+ +
+ {reportRangeError && {reportRangeError}} + +
+
+ {reportPreset === "custom" && ( +
+ Custom range enables a PM2.5 trend chart in the report (calibrated PM2.5, grouped by {seriesGroupBy}). +
+ )} +
+ {/* Selected Devices Counter */} {selectedDevices.length > 0 && (
@@ -1059,6 +1489,7 @@ function ReportContent() { // Show the report on page with a slight delay for visual effect setTimeout(() => { setShowReportOnPage(true) + void refreshReportMetrics() setReportGenerating(false) // Scroll to the report @@ -1128,6 +1559,7 @@ function ReportContent() { // Show the report on page with a slight delay for visual effect setTimeout(() => { setShowReportOnPage(true) + void refreshReportMetrics() setReportGenerating(false) }, 800) } @@ -1179,7 +1611,13 @@ function ReportContent() { {/* Report Action Buttons */}
+ {false && ( +
+
+ Report period +
+ + + + +
+ + + + + + +
+
+ + setCustomStart(new Date(e.target.value))} + /> +
+
+ + setCustomEnd(new Date(e.target.value))} + /> +
+
+ + +
+
+
+
+ + + {format(reportStart, "MMM d, yyyy HH:mm")} – {format(reportEnd, "MMM d, yyyy HH:mm")} + +
+ +
+ {reportRangeError && {reportRangeError}} + +
+
+ )} +
{/* Report Header */}
@@ -1251,8 +1771,120 @@ function ReportContent() { {getLocationInfo().city}, {getLocationInfo().country}

Report Date: {format(new Date(), "MMMM d, yyyy")}

+

+ Report Period: {format(reportStart, "MMM d, yyyy HH:mm")} – {format(reportEnd, "MMM d, yyyy HH:mm")} ( + {formatPresetLabel(reportPreset)}) +

+ {reportPreset === "custom" && showTrendChart && ( + + +
+ PM2.5 trend for your custom range +
+ Hue by + + + Series + + + +
+
+
+ + {timeSeriesSummary.length > 0 && ( +
+ {timeSeriesSummary.map((row) => ( +
+
{row.key}
+
+ {(row.avg ?? 0).toFixed(1)} µg/m³ +
+
+ Range: {row.min.toFixed(1)} – {row.max.toFixed(1)} ({row.count} pts) +
+
+ ))} +
+ )} + {timeSeriesLoading ? ( +
+ + +
+ ) : timeSeriesError ? ( +
{timeSeriesError}
+ ) : timeSeriesChart.data.length === 0 ? ( +
+ No measurements available for this custom range. Try widening the time window. +
+ ) : ( +
+ + + + + + + value === null || value === undefined || Number.isNaN(Number(value)) + ? "—" + : `${Number(value).toFixed(1)} µg/m³` + } + /> + + {timeSeriesChart.keys.map((key) => ( + + ))} + + +
+ )} + {timeSeriesNotice &&
{timeSeriesNotice}
} +
+
+ )} + {/* Map snapshot of selected area */}
@@ -1320,7 +1952,7 @@ function ReportContent() { {meta.label}
- PM2.5: {(site.pm2_5?.value ?? 0).toFixed(1)} µg/m³ + PM2.5 (avg): {(site.pm2_5?.value ?? 0).toFixed(1)} µg/m³
{site.siteDetails.city || "Unknown City"}, {site.siteDetails.country || "Unknown"} @@ -1708,6 +2340,7 @@ function ReportContent() { // Show the report on page with a slight delay for visual effect setTimeout(() => { setShowReportOnPage(true) + void refreshReportMetrics() setReportGenerating(false) // Scroll to the report @@ -1990,7 +2623,7 @@ function SiteCard({
- Current PM2.5 + Avg PM2.5 (calibrated)
{pm25Value.toFixed(2)} µg/m³
diff --git a/frontend/src/services/apiService.tsx b/frontend/src/services/apiService.tsx index 7671aa3..df5ec89 100644 --- a/frontend/src/services/apiService.tsx +++ b/frontend/src/services/apiService.tsx @@ -76,6 +76,99 @@ interface HeatmapData { message: string } +export interface ReportRangeParams { + siteIds: string[] + startTime: string + endTime: string +} + +export interface SiteReportMetrics { + site_id: string + pm2_5_avg: number | null + pm10_avg: number | null + count: number + lastTime: string | null + aqi_category: string + aqi_color: string +} + +export interface ReportTimeSeriesPoint { + site_id: string + time: string + pm2_5: number | null + siteName: string + city: string +} + +const isFiniteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) + +const mean = (values: number[]): number | null => { + if (values.length === 0) return null + return values.reduce((sum, value) => sum + value, 0) / values.length +} + +const extractPm25 = (measurement: any): number | null => { + const candidates = [ + measurement?.pm2_5?.calibrated?.value, + measurement?.pm2_5_calibrated?.value, + measurement?.pm2_5_calibrated_value, + measurement?.pm2_5?.value, + ] + for (const candidate of candidates) { + if (isFiniteNumber(candidate)) return candidate + } + return null +} + +const extractPm10 = (measurement: any): number | null => { + const candidates = [ + measurement?.pm10?.calibrated?.value, + measurement?.pm10_calibrated?.value, + measurement?.pm10_calibrated_value, + measurement?.pm10?.value, + ] + for (const candidate of candidates) { + if (isFiniteNumber(candidate)) return candidate + } + return null +} + +const computeAqiFromPm25 = ( + pm25: number | null, +): { + aqi_category: string + aqi_color: string +} => { + if (!isFiniteNumber(pm25)) { + return { aqi_category: "Unknown", aqi_color: "#9CA3AF" } + } + + if (pm25 <= 12.0) return { aqi_category: "Good", aqi_color: "#22C55E" } + if (pm25 <= 35.4) return { aqi_category: "Moderate", aqi_color: "#EAB308" } + if (pm25 <= 55.4) return { aqi_category: "Unhealthy for Sensitive Groups", aqi_color: "#F97316" } + if (pm25 <= 150.4) return { aqi_category: "Unhealthy", aqi_color: "#EF4444" } + if (pm25 <= 250.4) return { aqi_category: "Very Unhealthy", aqi_color: "#A855F7" } + return { aqi_category: "Hazardous", aqi_color: "#7F1D1D" } +} + +const mapWithConcurrency = async ( + items: Item[], + concurrency: number, + mapper: (item: Item) => Promise, +): Promise => { + const results: Result[] = [] + const queue = [...items] + const workers = Array.from({ length: Math.max(1, concurrency) }, async () => { + while (queue.length > 0) { + const item = queue.shift() + if (item === undefined) return + results.push(await mapper(item)) + } + }) + await Promise.all(workers) + return results +} + // Satellite API service to fetch data with POST request export const getSatelliteData = async (body = {}) => { try { @@ -112,8 +205,7 @@ export const getMapNodes = async (): Promise => { } } -// Add this function to fetch report data -export const getReportData = async (): Promise => { +export const getReportSitesCatalog = async (): Promise => { try { const response = await apiService.get("/devices/readings/map", { params: { @@ -129,7 +221,119 @@ export const getReportData = async (): Promise => { return response.data.measurements } catch (error) { - console.error("Error fetching report data:", error) + console.error("Error fetching report catalog data:", error) + return null + } +} + +// Fetch report metrics from the sites measurements endpoint for multiple site IDs. +export const getReportData = async ({ siteIds, startTime, endTime }: ReportRangeParams): Promise => { + if (!siteIds.length) return [] + + const uniqueSiteIds = Array.from(new Set(siteIds.filter(Boolean))) + + try { + const metrics = await mapWithConcurrency(uniqueSiteIds, 10, async (siteId) => { + try { + const response = await apiService.get(`/devices/measurements/sites/${siteId}`, { + params: { + token: apiToken, + startTime, + endTime, + }, + }) + + const payload = Array.isArray(response.data) ? response.data[0] : response.data + const measurements: any[] = Array.isArray(payload?.measurements) ? payload.measurements : [] + + const pm25Values = measurements.map(extractPm25).filter(isFiniteNumber) + const pm10Values = measurements.map(extractPm10).filter(isFiniteNumber) + + const pm2_5_avg = mean(pm25Values) + const pm10_avg = mean(pm10Values) + + const lastTime = measurements + .map((measurement) => measurement?.time) + .filter((time) => typeof time === "string") + .sort() + .at(-1) ?? null + + const { aqi_category, aqi_color } = computeAqiFromPm25(pm2_5_avg) + + return { + site_id: siteId, + pm2_5_avg, + pm10_avg, + count: measurements.length, + lastTime, + aqi_category, + aqi_color, + } satisfies SiteReportMetrics + } catch (siteError) { + console.error(`Error fetching report metrics for site ${siteId}:`, siteError) + return null + } + }) + + return metrics.filter(Boolean) as SiteReportMetrics[] + } catch (error) { + console.error("Error fetching report metrics:", error) + return null + } +} + +export const getReportTimeSeries = async ({ + siteIds, + startTime, + endTime, +}: ReportRangeParams): Promise => { + if (!siteIds.length) return [] + + const uniqueSiteIds = Array.from(new Set(siteIds.filter(Boolean))) + + try { + const responses = await mapWithConcurrency(uniqueSiteIds, 8, async (siteId) => { + try { + const response = await apiService.get(`/devices/measurements/sites/${siteId}`, { + params: { + token: apiToken, + startTime, + endTime, + }, + }) + + const payload = Array.isArray(response.data) ? response.data[0] : response.data + const measurements: any[] = Array.isArray(payload?.measurements) ? payload.measurements : [] + + const points = measurements + .map((measurement) => { + const time = measurement?.time + if (typeof time !== "string") return null + + const siteDetails = measurement?.siteDetails ?? {} + const siteName = siteDetails?.name ?? siteDetails?.formatted_name ?? "Unknown Site" + const city = siteDetails?.city ?? "Unknown City" + + return { + site_id: siteId, + time, + pm2_5: extractPm25(measurement), + siteName, + city, + } satisfies ReportTimeSeriesPoint + }) + .filter(Boolean) as ReportTimeSeriesPoint[] + + return points + } catch (siteError) { + console.error(`Error fetching report timeseries for site ${siteId}:`, siteError) + return [] + } + }) + + return responses.flat() + } catch (error) { + console.error("Error fetching report timeseries:", error) return null } } @@ -171,4 +375,4 @@ export const getHeatmapData = async (): Promise => { console.error("All 5 attempts failed to fetch heatmap data"); return null; -} \ No newline at end of file +}