From d5294261d69435fff38d0c3ae2f64c250635b28a Mon Sep 17 00:00:00 2001 From: Anthony Zheng Date: Mon, 25 Aug 2025 19:15:30 -0400 Subject: [PATCH 1/6] ADDED INTEGRATION FUNCTION --- components/pages/StatsGraphTab.tsx | 23 +++++++++++++++++++++++ package.json | 2 +- pnpm-lock.yaml | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/components/pages/StatsGraphTab.tsx b/components/pages/StatsGraphTab.tsx index a755606..6abb9b4 100644 --- a/components/pages/StatsGraphTab.tsx +++ b/components/pages/StatsGraphTab.tsx @@ -30,6 +30,7 @@ import { calculateBatteryEnergyAh, calculateBatterySOC, calculateMotorPowerConsumption, + mapTelemetryData, } from "@/lib/telemetry-utils"; import { useEffect, useState } from "react"; import { ChevronDownIcon, Download } from "lucide-react"; @@ -55,6 +56,26 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { fetchTelemetryDataInRange } from "@/lib/db-utils"; +import { Telemetry } from "next/dist/telemetry/storage"; + +function integrateData(data: any, dataKey: string) { + let integral = 0 + + for (let i = 0; i < data.length - 1; i++) { + + let date1 = new Date(data[i + 1].timestamp); + let date2 = new Date(data[i].timestamp); + let changeX = date1.getTime() - date2.getTime(); + + let midVal = (data[i+1][dataKey] + data[i][dataKey])/2 + integral += (midVal*changeX)/1000 + + } + console.log(integral) + return integral +} + + // Configuration constant to enable/disable refresh interval const ENABLE_REFRESH_INTERVAL = false; @@ -619,6 +640,8 @@ export default function StatsGraphTab() { return validationError; } +integrateData(chartData, selectedDataKeys[0]) + return (
diff --git a/package.json b/package.json index 5c5b54c..bd1c192 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "postcss": "^8.5", "prettier": "^3.6.2", "tailwindcss": "^3.4.17", - "typescript": "^5" + "typescript": "^5.8.3" }, "packageManager": "pnpm@9.1.0+sha512.67f5879916a9293e5cf059c23853d571beaf4f753c707f40cb22bed5fb1578c6aad3b6c4107ccb3ba0b35be003eb621a16471ac836c87beb53f9d54bb4612724" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee7f220..a29d019 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,7 +205,7 @@ importers: specifier: ^3.4.17 version: 3.4.17 typescript: - specifier: ^5 + specifier: ^5.8.3 version: 5.8.3 packages: From dd0d6020dc09fe37400f899fe6df0e5b5abe4991 Mon Sep 17 00:00:00 2001 From: Anthony Zheng Date: Thu, 15 Jan 2026 19:39:05 -0500 Subject: [PATCH 2/6] fix: fixed derived value date display and date selector --- .DS_Store | Bin 0 -> 6148 bytes components/pages/StatsGraphTab.tsx | 82 ++++++++++++++++++++--------- lib/db-utils.ts | 5 +- 3 files changed, 61 insertions(+), 26 deletions(-) create mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0([]) + +useEffect(()=>{ + let calcIntegrals = [] + for (let i = 0; i < selectedDataKeys.length; i++ ) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])) + setintegrals(calcIntegrals) + } + + +},[chartData]) - return ( +return (
@@ -744,19 +762,22 @@ integrateData(chartData, selectedDataKeys[0])
- { - startDate?.setHours(parseInt(time.target.value.split(":")[0])); - startDate?.setMinutes( - parseInt(time.target.value.split(":")[1]), - ); - }} - className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" - /> + { + if (startDate) { + const newDate = new Date(startDate); + const [hours, minutes] = e.target.value.split(":"); + newDate.setHours(parseInt(hours)); + newDate.setMinutes(parseInt(minutes)); + setStartDate(newDate); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + />
@@ -789,13 +810,22 @@ integrateData(chartData, selectedDataKeys[0])
- + { + if (endDate) { + const newDate = new Date(endDate); + const [hours, minutes] = e.target.value.split(":"); + newDate.setHours(parseInt(hours)); + newDate.setMinutes(parseInt(minutes)); + setEndDate(newDate); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" +/>
@@ -949,6 +979,8 @@ integrateData(chartData, selectedDataKeys[0]) })} + {selectedDataKeys.map((key, index) => { + return
{key} : {integrals[index]}
})}
); } diff --git a/lib/db-utils.ts b/lib/db-utils.ts index 847e845..27e1fee 100644 --- a/lib/db-utils.ts +++ b/lib/db-utils.ts @@ -172,7 +172,10 @@ export async function fetchTelemetryDataInRange( } } - return mapTelemetryData(transformedRow); + return { + ...mapTelemetryData(transformedRow), + created_at: transformedRow.created_at, + }; }); } catch (error) { console.error("Error fetching telemetry data in range:", error); From 1dafd1f35be9e705d0b8c33041c621f3683c37e7 Mon Sep 17 00:00:00 2001 From: Anthony Zheng Date: Thu, 15 Jan 2026 21:37:58 -0500 Subject: [PATCH 3/6] fix --- components/pages/StatsGraphTab.tsx | 1845 +++++++++++++--------------- 1 file changed, 876 insertions(+), 969 deletions(-) diff --git a/components/pages/StatsGraphTab.tsx b/components/pages/StatsGraphTab.tsx index 5bda572..5b826ad 100644 --- a/components/pages/StatsGraphTab.tsx +++ b/components/pages/StatsGraphTab.tsx @@ -2,35 +2,31 @@ import { TelemetryData } from "@/lib/types"; import { CartesianGrid, Line, LineChart, XAxis, YAxis, Legend } from "recharts"; import { DropdownMenuCheckboxItemProps } from "@radix-ui/react-dropdown-menu"; import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, - ChartLegend, - ChartLegendContent, + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, } from "@/components/ui/chart"; import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { generateSelectGroups, getValueFromPath, TELEMETRY_FIELD_CONFIG } from "@/lib/chart-config"; import { - generateSelectGroups, - getValueFromPath, - TELEMETRY_FIELD_CONFIG, -} from "@/lib/chart-config"; -import { - getCustomValue, - calculateNetPower, - calculateMotorPower, - calculateTotalSolarPower, - calculateBatteryEnergyAh, - calculateBatterySOC, - calculateMotorPowerConsumption, - mapTelemetryData, + getCustomValue, + calculateNetPower, + calculateMotorPower, + calculateTotalSolarPower, + calculateBatteryEnergyAh, + calculateBatterySOC, + calculateMotorPowerConsumption, + mapTelemetryData, } from "@/lib/telemetry-utils"; import { useEffect, useState } from "react"; import { Axis3D, ChevronDownIcon, Download } from "lucide-react"; @@ -39,46 +35,42 @@ import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { fetchTelemetryDataInRange } from "@/lib/db-utils"; import SimpleCard from "../telemetry/SimpleCard"; import { Telemetry } from "next/dist/telemetry/storage"; function integrateData(data: any, dataKey: string) { - let integral = 0; + let integral = 0; - for (let i = 0; i < data.length - 1; i++) { - const val1 = data[i][dataKey]; - const val2 = data[i + 1][dataKey]; + for (let i = 0; i < data.length - 1; i++) { + const val1 = data[i][dataKey]; + const val2 = data[i + 1][dataKey]; - if (val1 == null || val2 == null) { - continue; - } + if (val1 == null || val2 == null) { + continue; + } - let date1 = new Date(data[i + 1].timestamp); - let date2 = new Date(data[i].timestamp); - let changeX = date1.getTime() - date2.getTime(); + let date1 = new Date(data[i + 1].timestamp); + let date2 = new Date(data[i].timestamp); + let changeX = date1.getTime() - date2.getTime(); - let midVal = (val2 + val1) / 2; - integral += (midVal * changeX) / 1000; - } - console.log("PENIS"); - return integral; + let midVal = (val2 + val1) / 2; + integral += (midVal * changeX) / 1000; + } + console.log("PENIS"); + return integral; } // Configuration constant to enable/disable refresh interval @@ -86,933 +78,848 @@ const ENABLE_REFRESH_INTERVAL = false; // Helper function to convert any date to CDT (UTC-5) function toCDT(date: Date | string): Date { - const d = new Date(date); + const d = new Date(date); - // Always subtract 1 hour regardless of environment - let adjustedTime = new Date(d.getTime() - 1 * 60 * 60 * 1000); // subtract 1 hour + // Always subtract 1 hour regardless of environment + let adjustedTime = new Date(d.getTime() - 1 * 60 * 60 * 1000); // subtract 1 hour - // Check if we're in local development (not production) - const isLocal = - process.env.NODE_ENV === "development" || - (typeof window !== "undefined" && window.location.hostname === "localhost"); + // Check if we're in local development (not production) + const isLocal = + process.env.NODE_ENV === "development" || + (typeof window !== "undefined" && window.location.hostname === "localhost"); - // If local, subtract additional 4 hours to match production timezone - if (isLocal) { - adjustedTime = new Date(adjustedTime.getTime() - 4 * 60 * 60 * 1000); // subtract additional 4 hours - } + // If local, subtract additional 4 hours to match production timezone + if (isLocal) { + adjustedTime = new Date(adjustedTime.getTime() - 4 * 60 * 60 * 1000); // subtract additional 4 hours + } - return adjustedTime; + return adjustedTime; } // Helper function to get label from data key function getLabelFromDataKey(dataKey: string): string { - // Handle custom fields - switch (dataKey) { - case "net_power": - return "Net Power"; - case "motor_power": - return "Motor Power"; - case "total_solar_power": - return "Total Solar Power"; - case "mppt_sum": - return "Total MPPT Voltage Output"; - case "battery_energy_ah": - return "Battery Remaining Energy (Ah)"; - case "battery_soc": - return "Battery SOC (%)"; - case "motor_power_consumption": - return "Motor Power Consumption"; - } - - // Convert from database format (e.g., "battery_main_bat_v") to config format (e.g., "battery.main_bat_v") - // Find the first underscore to separate category from the rest - const firstUnderscoreIndex = dataKey.indexOf("_"); - if (firstUnderscoreIndex === -1) { - return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); - } - - const category = dataKey.substring(0, firstUnderscoreIndex); - const field = dataKey.substring(firstUnderscoreIndex + 1); - - if (category && field && TELEMETRY_FIELD_CONFIG[category]?.fields[field]) { - return TELEMETRY_FIELD_CONFIG[category].fields[field]; - } - - // Fallback to formatted version of the key - return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); + // Handle custom fields + switch (dataKey) { + case "net_power": + return "Net Power"; + case "motor_power": + return "Motor Power"; + case "total_solar_power": + return "Total Solar Power"; + case "mppt_sum": + return "Total MPPT Voltage Output"; + case "battery_energy_ah": + return "Battery Remaining Energy (Ah)"; + case "battery_soc": + return "Battery SOC (%)"; + case "motor_power_consumption": + return "Motor Power Consumption"; + } + + // Convert from database format (e.g., "battery_main_bat_v") to config format (e.g., "battery.main_bat_v") + // Find the first underscore to separate category from the rest + const firstUnderscoreIndex = dataKey.indexOf("_"); + if (firstUnderscoreIndex === -1) { + return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); + } + + const category = dataKey.substring(0, firstUnderscoreIndex); + const field = dataKey.substring(firstUnderscoreIndex + 1); + + if (category && field && TELEMETRY_FIELD_CONFIG[category]?.fields[field]) { + return TELEMETRY_FIELD_CONFIG[category].fields[field]; + } + + // Fallback to formatted version of the key + return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); } // Generate dynamic chart config based on selected data keys -function generateChartConfig( - selectedDataKeys: string[], - lineColors: string[] -): ChartConfig { - const config: ChartConfig = {}; - - selectedDataKeys.forEach((key, index) => { - config[key] = { - label: getLabelFromDataKey(key), - color: lineColors[index % lineColors.length], - }; - }); - - return config; +function generateChartConfig(selectedDataKeys: string[], lineColors: string[]): ChartConfig { + const config: ChartConfig = {}; + + selectedDataKeys.forEach((key, index) => { + config[key] = { + label: getLabelFromDataKey(key), + color: lineColors[index % lineColors.length], + }; + }); + + return config; } export default function StatsGraphTab() { - const [open1, setOpen1] = useState(false); - const [open2, setOpen2] = useState(false); - const [multiselectEnabled, setMultiselectEnabled] = useState(false); - const [selectedDataKeys, setSelectedDataKeys] = useState([ - "battery_main_bat_v", - ]); - const [chartData, setChartData] = useState([]); - const [startDate, setStartDate] = useState(() => { - return new Date("2025-07-04T01:00:00"); - }); - const [refreshInterval, setRefreshInterval] = useState( - null - ); - const [endDate, setEndDate] = useState(() => { - return new Date("2025-07-06T01:00:00"); - }); - - const [lineColors] = useState([ - "#8884D8", - "#D88884", - "#84D888", - "#884D88", - "#88884D", - "#4D8888", - ]); - - const [minYTrim, setMinYTrim] = useState(undefined); - const [maxYTrim, setMaxYTrim] = useState(undefined); - - // Validation function to check for errors - const getValidationError = () => { - // Validate data and dateData are not null - if (chartData === null) { - return ( -
-
-

Data Error

-

- Chart data is null or invalid -

-
-
- ); - } - - if (startDate === null || endDate === null) { - return ( -
-
-

Date Error

-

- Date data is null or invalid -

-
-
- ); - } - - // Iterate through all data points and check for null values - if (chartData.length > 0) { - const hasNullData = chartData.some((dataPoint) => { - if (dataPoint === null || dataPoint === undefined) { - return true; - } - // Check if any selected data key has null values - return selectedDataKeys.some((key) => { - const value = dataPoint[key]; - return value === null; - }); - }); - - if (hasNullData) { - return ( -
-
-

Data Error

-

- Some data points contain null values -

-
-
- ); - } - } - - return null; - }; - - const selectGroups = generateSelectGroups(); - - // CSV Export function - const exportToCSV = () => { - if (filteredChartData.length === 0) { - return; - } - - // Create CSV headers - const headers = ["timestamp", ...selectedDataKeys]; - const csvHeaders = headers - .map((header) => - header === "timestamp" ? "Timestamp" : getLabelFromDataKey(header) - ) - .join(","); - - // Create CSV rows - const csvRows = filteredChartData.map((dataPoint) => { - return headers - .map((header) => { - if (header === "timestamp") { - const date = new Date(dataPoint.timestamp); - return `"${date.toLocaleString()}"`; - } - const value = dataPoint[header]; - return value !== undefined && value !== null ? value : ""; - }) - .join(","); - }); - - // Combine headers and rows - const csvContent = [csvHeaders, ...csvRows].join("\n"); - - // Create and download file - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const link = document.createElement("a"); - const url = URL.createObjectURL(blob); - link.setAttribute("href", url); - - // Generate filename with date range - const startStr = - startDate?.toLocaleDateString().replace(/\//g, "-") || "start"; - const endStr = endDate?.toLocaleDateString().replace(/\//g, "-") || "end"; - link.setAttribute( - "download", - `telemetry-data-${startStr}-to-${endStr}.csv` - ); - - link.style.visibility = "hidden"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - - const handleValueChange = (values: string[]) => { - setSelectedDataKeys(values.map((v) => v.replace(".", "_"))); - }; - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - } - setintegrals(calcIntegrals); - }, [selectedDataKeys, chartData]); - - useEffect(() => { - if (selectedDataKeys.length > 0 && startDate && endDate) { - // Check if any custom fields are selected - const customFields = selectedDataKeys.filter( - ( - key - ): key is - | "net_power" - | "motor_power" - | "total_solar_power" - | "mppt_sum" - | "battery_energy_ah" - | "battery_soc" - | "motor_power_consumption" => - key === "net_power" || - key === "motor_power" || - key === "total_solar_power" || - key === "mppt_sum" || - key === "battery_energy_ah" || - key === "battery_soc" || - key === "motor_power_consumption" - ); - const regularFields = selectedDataKeys.filter( - (key) => - ![ - "net_power", - "motor_power", - "total_solar_power", - "mppt_sum", - "battery_energy_ah", - "battery_soc", - "motor_power_consumption", - ].includes(key) - ); - - if (customFields.length > 0) { - // If custom fields are selected, fetch all telemetry data - fetchTelemetryDataInRange(startDate, endDate).then((allData) => { - if (!allData || !Array.isArray(allData)) return; - - const processedData = (allData as TelemetryData[]) - .map((dataPoint: any) => { - const result: any = { - timestamp: toCDT( - dataPoint.created_at || dataPoint.gps?.rx_time || new Date() - ), - }; - - // Add regular fields - regularFields.forEach((field) => { - const firstUnderscore = field.indexOf("_"); - if (firstUnderscore !== -1) { - const category = field.substring(0, firstUnderscore); - const fieldName = field.substring(firstUnderscore + 1); - const value = getValueFromPath( - dataPoint, - `${category}.${fieldName}` - ); - if (value !== undefined) { - result[field] = value; - } - } - }); - - // Calculate custom fields - customFields.forEach((field) => { - switch (field) { - case "net_power": - result[field] = calculateNetPower(dataPoint); - break; - case "motor_power": - result[field] = calculateMotorPower(dataPoint); - break; - case "total_solar_power": - result[field] = calculateTotalSolarPower(dataPoint); - break; - case "mppt_sum": - result[field] = - dataPoint.mppt1.output_v + - dataPoint.mppt2.output_v + - dataPoint.mppt3.output_v; - break; - case "net_power": - // Calculate battery power: voltage * current - // Only include if both voltage and current are non-zero - const voltage = dataPoint.battery?.main_bat_v; - const current = dataPoint.battery?.main_bat_c; - if (voltage && current) { - result[field] = voltage * current; - } else { - result[field] = 0; - } - break; - case "battery_energy_ah": - result[field] = calculateBatteryEnergyAh(dataPoint); - break; - case "battery_soc": - result[field] = calculateBatterySOC(dataPoint); - break; - case "motor_power_consumption": - const motorPowerConsumption = - calculateMotorPowerConsumption(dataPoint); - result[field] = motorPowerConsumption; - break; - } - }); - - return result; - }) - .filter((dataPoint) => { - // Filter out data points where any selected custom field has a value of 0 or null - return !customFields.some( - (field) => dataPoint[field] === 0 || dataPoint[field] === null - ); - }); - - setChartData(processedData); - }); - } else { - // If no custom fields, use the original approach - Promise.all( - selectedDataKeys.map((key) => - fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ - key, - data, - })) - ) - ).then((results) => { - const mergedData: { [timestamp: string]: any } = {}; - results.forEach(({ key, data }) => { - data?.forEach((point: any) => { - const cdtTimestamp = toCDT(point.timestamp); - const ts = cdtTimestamp.toISOString(); - if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; - mergedData[ts][key] = point.value; - }); - }); - const mergedArray = Object.values(mergedData).sort( - (a: any, b: any) => - new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); - setChartData(mergedArray); - }); - } - } - }, [selectedDataKeys, startDate, endDate]); - - useEffect(() => { - if (refreshInterval) { - clearInterval(refreshInterval); - } - - // Only set up refresh interval if enabled - if (!ENABLE_REFRESH_INTERVAL) { - return; - } - - const interval = setInterval(() => { - if (selectedDataKeys.length > 0 && startDate && endDate) { - // Check if any custom fields are selected - const customFields = selectedDataKeys.filter( - ( - key - ): key is - | "net_power" - | "motor_power" - | "total_solar_power" - | "mppt_sum" - | "battery_energy_ah" - | "battery_soc" - | "motor_power_consumption" => - key === "net_power" || - key === "motor_power" || - key === "total_solar_power" || - key === "mppt_sum" || - key === "battery_energy_ah" || - key === "battery_soc" || - key === "motor_power_consumption" - ); - const regularFields = selectedDataKeys.filter( - (key) => - ![ - "net_power", - "motor_power", - "total_solar_power", - "mppt_sum", - "battery_energy_ah", - "battery_soc", - "motor_power_consumption", - ].includes(key) - ); - - if (customFields.length > 0) { - // If custom fields are selected, fetch all telemetry data - fetchTelemetryDataInRange(startDate, endDate).then((allData) => { - if (!allData || !Array.isArray(allData)) return; - - const processedData = (allData as TelemetryData[]) - .map((dataPoint: any) => { - const result: any = { - timestamp: toCDT( - dataPoint.created_at || dataPoint.gps?.rx_time || new Date() - ), - }; - - // Add regular fields - regularFields.forEach((field) => { - const firstUnderscore = field.indexOf("_"); - if (firstUnderscore !== -1) { - const category = field.substring(0, firstUnderscore); - const fieldName = field.substring(firstUnderscore + 1); - const value = getValueFromPath( - dataPoint, - `${category}.${fieldName}` - ); - if (value !== undefined) { - result[field] = value; - } - } - }); - - // Calculate custom fields - customFields.forEach((field) => { - switch (field) { - case "net_power": - result[field] = calculateNetPower(dataPoint); - break; - case "motor_power": - result[field] = calculateMotorPower(dataPoint); - break; - case "total_solar_power": - result[field] = calculateTotalSolarPower(dataPoint); - break; - case "mppt_sum": - result[field] = - dataPoint.mppt1.output_v + - dataPoint.mppt2.output_v + - dataPoint.mppt3.output_v; - break; - case "net_power": - // Calculate battery power: voltage * current - // Only include if both voltage and current are non-zero - const voltage = dataPoint.battery?.main_bat_v; - const current = dataPoint.battery?.main_bat_c; - if (voltage && current) { - result[field] = voltage * current; - } else { - result[field] = 0; - } - break; - case "battery_energy_ah": - result[field] = calculateBatteryEnergyAh(dataPoint); - break; - case "battery_soc": - result[field] = calculateBatterySOC(dataPoint); - break; - case "motor_power_consumption": - const motorPowerConsumption = - calculateMotorPowerConsumption(dataPoint); - result[field] = motorPowerConsumption; - break; - } - }); - - return result; - }) - .filter((dataPoint) => { - // Filter out data points where any selected custom field has a value of 0 or null - return !customFields.some( - (field) => dataPoint[field] === 0 || dataPoint[field] === null - ); - }); - - setChartData(processedData); - }); - } else { - // If no custom fields, use the original approach - Promise.all( - selectedDataKeys.map((key) => - fetchTelemetryDataInRange(startDate, endDate, key).then( - (data) => ({ - key, - data, - }) - ) - ) - ).then((results) => { - const mergedData: { [timestamp: string]: any } = {}; - results.forEach(({ key, data }) => { - data?.forEach((point: any) => { - const cdtTimestamp = toCDT(point.timestamp); - const ts = cdtTimestamp.toISOString(); - if (!mergedData[ts]) - mergedData[ts] = { timestamp: cdtTimestamp }; - mergedData[ts][key] = point.value; - }); - }); - const mergedArray = Object.values(mergedData).sort( - (a: any, b: any) => - new Date(a.timestamp).getTime() - - new Date(b.timestamp).getTime() - ); - setChartData(mergedArray); - }); - } - } - }, 2000); - setRefreshInterval(interval); - - return () => clearInterval(interval); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDataKeys, startDate, endDate]); - - // Filter chart data based on Y trim values - const filteredChartData = chartData.filter((dataPoint) => { - // Check if any of the selected data keys have values outside the trim range - for (const key of selectedDataKeys) { - const value = dataPoint[key]; - if (value !== undefined && value !== null) { - if (minYTrim !== undefined && value < minYTrim) return false; - if (maxYTrim !== undefined && value > maxYTrim) return false; - } - } - return true; - }); - - // Generate dynamic chart - const chartConfig = generateChartConfig(selectedDataKeys, lineColors); - - // Check for validation errors and return early if any exist - const validationError = getValidationError(); - if (validationError) { - return validationError; - } - - const [integrals, setintegrals] = useState([]); - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - setintegrals(calcIntegrals); - } - }, [chartData]); - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - setintegrals(calcIntegrals); - } - }, [chartData]); - - return ( -
-
-
- - - - - - {selectGroups.map((group) => ( -
- {group.label} - - {group.options.map((option) => ( - { - const value = option.value.replace(".", "_"); - let updatedKeys: string[]; - - if (multiselectEnabled) { - // Multiselect mode: add/remove from array - updatedKeys = checked - ? [...selectedDataKeys, value] - : selectedDataKeys.filter((key) => key !== value); - } else { - // Single select mode: replace selection - updatedKeys = checked ? [value] : []; - } - - handleValueChange(updatedKeys); - }} - > - {option.label} - - ))} -
- ))} -
-
-
- { - setMultiselectEnabled(checked as boolean); - // If disabling multiselect and multiple items are selected, keep only the first one - if (!checked && selectedDataKeys.length > 1) { - setSelectedDataKeys([selectedDataKeys[0]]); - } - }} - /> - -
-
-
-
- - - - - - { - setStartDate(date); - setOpen1(false); - }} - /> - - -
-
- { - if (startDate) { - const newDate = new Date(startDate); - const [hours, minutes] = e.target.value.split(":"); - newDate.setHours(parseInt(hours)); - newDate.setMinutes(parseInt(minutes)); - setStartDate(newDate); - } - }} - className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" - /> -
-
-
-
- - - - - - { - setEndDate(date); - setOpen2(false); - }} - /> - - -
-
- { - if (endDate) { - const newDate = new Date(endDate); - const [hours, minutes] = e.target.value.split(":"); - newDate.setHours(parseInt(hours)); - newDate.setMinutes(parseInt(minutes)); - setEndDate(newDate); - } - }} - className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" - /> -
-
-
- - {/* Y-Axis Trim Controls and Export */} -
-
- - { - const value = e.target.value; - setMinYTrim(value === "" ? undefined : parseFloat(value)); - }} - className="w-32" - /> -
-
- - { - const value = e.target.value; - setMaxYTrim(value === "" ? undefined : parseFloat(value)); - }} - className="w-32" - /> -
-
- - - - - - - - Export Telemetry Data - - This will download a CSV file containing{" "} - {filteredChartData.length} data points for the selected fields - from {startDate?.toLocaleDateString()} to{" "} - {endDate?.toLocaleDateString()}. - {selectedDataKeys.length > 0 && ( - - Selected fields:{" "} - {selectedDataKeys - .map((key) => getLabelFromDataKey(key)) - .join(", ")} - - )} - - - - Cancel - - Download CSV - - - - -
-
- -

- - - - { - const date = value instanceof Date ? value : new Date(value); - return date.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - }} - /> - - Number.parseFloat(value.toFixed(1)).toString() - } - /> - { - // Get the timestamp from the payload data - if (payload && payload.length > 0 && payload[0].payload) { - const timestamp = payload[0].payload.timestamp; - try { - const date = new Date(timestamp); - if (!isNaN(date.getTime())) { - return date.toLocaleString(); - } - } catch (error) { - console.error("Error parsing timestamp:", error); - } - } - return String(value); - }} - /> - } - /> - } /> - {selectedDataKeys.map((key) => { - return ( - - ); - })} - - -
-
- {selectedDataKeys.map((key, index) => { - const value = integrals[index]; - const title = - "Integral Value of " + - getLabelFromDataKey(key) + - " " + - Number(value).toFixed(2); - return ( - - ); - })} -
- {selectedDataKeys.map((key, index) => { - return ( -
- {key} : {integrals[index]} -
- ); - })} -
- ); + const [open1, setOpen1] = useState(false); + const [open2, setOpen2] = useState(false); + const [multiselectEnabled, setMultiselectEnabled] = useState(false); + const [selectedDataKeys, setSelectedDataKeys] = useState(["battery_main_bat_v"]); + const [chartData, setChartData] = useState([]); + const [startDate, setStartDate] = useState(() => { + return new Date("2025-07-04T01:00:00"); + }); + const [refreshInterval, setRefreshInterval] = useState(null); + const [endDate, setEndDate] = useState(() => { + return new Date("2025-07-06T01:00:00"); + }); + + const [lineColors] = useState(["#8884D8", "#D88884", "#84D888", "#884D88", "#88884D", "#4D8888"]); + + const [minYTrim, setMinYTrim] = useState(undefined); + const [maxYTrim, setMaxYTrim] = useState(undefined); + + // Validation function to check for errors + const getValidationError = () => { + // Validate data and dateData are not null + if (chartData === null) { + return ( +
+
+

Data Error

+

Chart data is null or invalid

+
+
+ ); + } + + if (startDate === null || endDate === null) { + return ( +
+
+

Date Error

+

Date data is null or invalid

+
+
+ ); + } + + // Iterate through all data points and check for null values + if (chartData.length > 0) { + const hasNullData = chartData.some((dataPoint) => { + if (dataPoint === null || dataPoint === undefined) { + return true; + } + // Check if any selected data key has null values + return selectedDataKeys.some((key) => { + const value = dataPoint[key]; + return value === null; + }); + }); + + if (hasNullData) { + return ( +
+
+

Data Error

+

Some data points contain null values

+
+
+ ); + } + } + + return null; + }; + + const selectGroups = generateSelectGroups(); + + // CSV Export function + const exportToCSV = () => { + if (filteredChartData.length === 0) { + return; + } + + // Create CSV headers + const headers = ["timestamp", ...selectedDataKeys]; + const csvHeaders = headers + .map((header) => (header === "timestamp" ? "Timestamp" : getLabelFromDataKey(header))) + .join(","); + + // Create CSV rows + const csvRows = filteredChartData.map((dataPoint) => { + return headers + .map((header) => { + if (header === "timestamp") { + const date = new Date(dataPoint.timestamp); + return `"${date.toLocaleString()}"`; + } + const value = dataPoint[header]; + return value !== undefined && value !== null ? value : ""; + }) + .join(","); + }); + + // Combine headers and rows + const csvContent = [csvHeaders, ...csvRows].join("\n"); + + // Create and download file + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + + // Generate filename with date range + const startStr = startDate?.toLocaleDateString().replace(/\//g, "-") || "start"; + const endStr = endDate?.toLocaleDateString().replace(/\//g, "-") || "end"; + link.setAttribute("download", `telemetry-data-${startStr}-to-${endStr}.csv`); + + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleValueChange = (values: string[]) => { + setSelectedDataKeys(values.map((v) => v.replace(".", "_"))); + }; + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + } + setintegrals(calcIntegrals); + }, [selectedDataKeys, chartData]); + + useEffect(() => { + if (selectedDataKeys.length > 0 && startDate && endDate) { + // Check if any custom fields are selected + const customFields = selectedDataKeys.filter( + ( + key + ): key is + | "net_power" + | "motor_power" + | "total_solar_power" + | "mppt_sum" + | "battery_energy_ah" + | "battery_soc" + | "motor_power_consumption" => + key === "net_power" || + key === "motor_power" || + key === "total_solar_power" || + key === "mppt_sum" || + key === "battery_energy_ah" || + key === "battery_soc" || + key === "motor_power_consumption" + ); + const regularFields = selectedDataKeys.filter( + (key) => + ![ + "net_power", + "motor_power", + "total_solar_power", + "mppt_sum", + "battery_energy_ah", + "battery_soc", + "motor_power_consumption", + ].includes(key) + ); + + if (customFields.length > 0) { + // If custom fields are selected, fetch all telemetry data + fetchTelemetryDataInRange(startDate, endDate).then((allData) => { + if (!allData || !Array.isArray(allData)) return; + + const processedData = (allData as TelemetryData[]) + .map((dataPoint: any) => { + const result: any = { + timestamp: toCDT(dataPoint.created_at || dataPoint.gps?.rx_time || new Date()), + }; + + // Add regular fields + regularFields.forEach((field) => { + const firstUnderscore = field.indexOf("_"); + if (firstUnderscore !== -1) { + const category = field.substring(0, firstUnderscore); + const fieldName = field.substring(firstUnderscore + 1); + const value = getValueFromPath(dataPoint, `${category}.${fieldName}`); + if (value !== undefined) { + result[field] = value; + } + } + }); + + // Calculate custom fields + customFields.forEach((field) => { + switch (field) { + case "net_power": + result[field] = calculateNetPower(dataPoint); + break; + case "motor_power": + result[field] = calculateMotorPower(dataPoint); + break; + case "total_solar_power": + result[field] = calculateTotalSolarPower(dataPoint); + break; + case "mppt_sum": + result[field] = + dataPoint.mppt1.output_v + + dataPoint.mppt2.output_v + + dataPoint.mppt3.output_v; + break; + case "net_power": + // Calculate battery power: voltage * current + // Only include if both voltage and current are non-zero + const voltage = dataPoint.battery?.main_bat_v; + const current = dataPoint.battery?.main_bat_c; + if (voltage && current) { + result[field] = voltage * current; + } else { + result[field] = 0; + } + break; + case "battery_energy_ah": + result[field] = calculateBatteryEnergyAh(dataPoint); + break; + case "battery_soc": + result[field] = calculateBatterySOC(dataPoint); + break; + case "motor_power_consumption": + const motorPowerConsumption = calculateMotorPowerConsumption(dataPoint); + result[field] = motorPowerConsumption; + break; + } + }); + + return result; + }) + .filter((dataPoint) => { + // Filter out data points where any selected custom field has a value of 0 or null + return !customFields.some((field) => dataPoint[field] === 0 || dataPoint[field] === null); + }); + + setChartData(processedData); + }); + } else { + // If no custom fields, use the original approach + Promise.all( + selectedDataKeys.map((key) => + fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ + key, + data, + })) + ) + ).then((results) => { + const mergedData: { [timestamp: string]: any } = {}; + results.forEach(({ key, data }) => { + data?.forEach((point: any) => { + const cdtTimestamp = toCDT(point.timestamp); + const ts = cdtTimestamp.toISOString(); + if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; + mergedData[ts][key] = point.value; + }); + }); + const mergedArray = Object.values(mergedData).sort( + (a: any, b: any) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + setChartData(mergedArray); + }); + } + } + }, [selectedDataKeys, startDate, endDate]); + + useEffect(() => { + if (refreshInterval) { + clearInterval(refreshInterval); + } + + // Only set up refresh interval if enabled + if (!ENABLE_REFRESH_INTERVAL) { + return; + } + + const interval = setInterval(() => { + if (selectedDataKeys.length > 0 && startDate && endDate) { + // Check if any custom fields are selected + const customFields = selectedDataKeys.filter( + ( + key + ): key is + | "net_power" + | "motor_power" + | "total_solar_power" + | "mppt_sum" + | "battery_energy_ah" + | "battery_soc" + | "motor_power_consumption" => + key === "net_power" || + key === "motor_power" || + key === "total_solar_power" || + key === "mppt_sum" || + key === "battery_energy_ah" || + key === "battery_soc" || + key === "motor_power_consumption" + ); + const regularFields = selectedDataKeys.filter( + (key) => + ![ + "net_power", + "motor_power", + "total_solar_power", + "mppt_sum", + "battery_energy_ah", + "battery_soc", + "motor_power_consumption", + ].includes(key) + ); + + if (customFields.length > 0) { + // If custom fields are selected, fetch all telemetry data + fetchTelemetryDataInRange(startDate, endDate).then((allData) => { + if (!allData || !Array.isArray(allData)) return; + + const processedData = (allData as TelemetryData[]) + .map((dataPoint: any) => { + const result: any = { + timestamp: toCDT(dataPoint.created_at || dataPoint.gps?.rx_time || new Date()), + }; + + // Add regular fields + regularFields.forEach((field) => { + const firstUnderscore = field.indexOf("_"); + if (firstUnderscore !== -1) { + const category = field.substring(0, firstUnderscore); + const fieldName = field.substring(firstUnderscore + 1); + const value = getValueFromPath(dataPoint, `${category}.${fieldName}`); + if (value !== undefined) { + result[field] = value; + } + } + }); + + // Calculate custom fields + customFields.forEach((field) => { + switch (field) { + case "net_power": + result[field] = calculateNetPower(dataPoint); + break; + case "motor_power": + result[field] = calculateMotorPower(dataPoint); + break; + case "total_solar_power": + result[field] = calculateTotalSolarPower(dataPoint); + break; + case "mppt_sum": + result[field] = + dataPoint.mppt1.output_v + + dataPoint.mppt2.output_v + + dataPoint.mppt3.output_v; + break; + case "net_power": + // Calculate battery power: voltage * current + // Only include if both voltage and current are non-zero + const voltage = dataPoint.battery?.main_bat_v; + const current = dataPoint.battery?.main_bat_c; + if (voltage && current) { + result[field] = voltage * current; + } else { + result[field] = 0; + } + break; + case "battery_energy_ah": + result[field] = calculateBatteryEnergyAh(dataPoint); + break; + case "battery_soc": + result[field] = calculateBatterySOC(dataPoint); + break; + case "motor_power_consumption": + const motorPowerConsumption = calculateMotorPowerConsumption(dataPoint); + result[field] = motorPowerConsumption; + break; + } + }); + + return result; + }) + .filter((dataPoint) => { + // Filter out data points where any selected custom field has a value of 0 or null + return !customFields.some( + (field) => dataPoint[field] === 0 || dataPoint[field] === null + ); + }); + + setChartData(processedData); + }); + } else { + // If no custom fields, use the original approach + Promise.all( + selectedDataKeys.map((key) => + fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ + key, + data, + })) + ) + ).then((results) => { + const mergedData: { [timestamp: string]: any } = {}; + results.forEach(({ key, data }) => { + data?.forEach((point: any) => { + const cdtTimestamp = toCDT(point.timestamp); + const ts = cdtTimestamp.toISOString(); + if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; + mergedData[ts][key] = point.value; + }); + }); + const mergedArray = Object.values(mergedData).sort( + (a: any, b: any) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + setChartData(mergedArray); + }); + } + } + }, 2000); + setRefreshInterval(interval); + + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDataKeys, startDate, endDate]); + + // Filter chart data based on Y trim values + const filteredChartData = chartData.filter((dataPoint) => { + // Check if any of the selected data keys have values outside the trim range + for (const key of selectedDataKeys) { + const value = dataPoint[key]; + if (value !== undefined && value !== null) { + if (minYTrim !== undefined && value < minYTrim) return false; + if (maxYTrim !== undefined && value > maxYTrim) return false; + } + } + return true; + }); + + // Generate dynamic chart + const chartConfig = generateChartConfig(selectedDataKeys, lineColors); + + // Check for validation errors and return early if any exist + const validationError = getValidationError(); + if (validationError) { + return validationError; + } + + const [integrals, setintegrals] = useState([]); + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + setintegrals(calcIntegrals); + } + }, [chartData]); + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + setintegrals(calcIntegrals); + } + }, [chartData]); + + return ( +
+
+
+ + + + + + {selectGroups.map((group) => ( +
+ {group.label} + + {group.options.map((option) => ( + { + const value = option.value.replace(".", "_"); + let updatedKeys: string[]; + + if (multiselectEnabled) { + // Multiselect mode: add/remove from array + updatedKeys = checked + ? [...selectedDataKeys, value] + : selectedDataKeys.filter((key) => key !== value); + } else { + // Single select mode: replace selection + updatedKeys = checked ? [value] : []; + } + + handleValueChange(updatedKeys); + }} + > + {option.label} + + ))} +
+ ))} +
+
+
+ { + setMultiselectEnabled(checked as boolean); + // If disabling multiselect and multiple items are selected, keep only the first one + if (!checked && selectedDataKeys.length > 1) { + setSelectedDataKeys([selectedDataKeys[0]]); + } + }} + /> + +
+
+
+
+ + + + + + { + setStartDate(date); + setOpen1(false); + }} + /> + + +
+
+ { + if (startDate) { + const newDate = new Date(startDate); + const [hours, minutes] = e.target.value.split(":"); + newDate.setHours(parseInt(hours)); + newDate.setMinutes(parseInt(minutes)); + setStartDate(newDate); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + /> +
+
+
+
+ + + + + + { + setEndDate(date); + setOpen2(false); + }} + /> + + +
+
+ { + if (endDate) { + const newDate = new Date(endDate); + const [hours, minutes] = e.target.value.split(":"); + newDate.setHours(parseInt(hours)); + newDate.setMinutes(parseInt(minutes)); + setEndDate(newDate); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + /> +
+
+
+ + {/* Y-Axis Trim Controls and Export */} +
+
+ + { + const value = e.target.value; + setMinYTrim(value === "" ? undefined : parseFloat(value)); + }} + className="w-32" + /> +
+
+ + { + const value = e.target.value; + setMaxYTrim(value === "" ? undefined : parseFloat(value)); + }} + className="w-32" + /> +
+
+ + + + + + + + Export Telemetry Data + + This will download a CSV file containing {filteredChartData.length} data points for + the selected fields from {startDate?.toLocaleDateString()} to{" "} + {endDate?.toLocaleDateString()}. + {selectedDataKeys.length > 0 && ( + + Selected fields:{" "} + {selectedDataKeys.map((key) => getLabelFromDataKey(key)).join(", ")} + + )} + + + + Cancel + Download CSV + + + +
+
+ +

+ + + + { + const date = value instanceof Date ? value : new Date(value); + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + }} + /> + Number.parseFloat(value.toFixed(1)).toString()} + /> + { + // Get the timestamp from the payload data + if (payload && payload.length > 0 && payload[0].payload) { + const timestamp = payload[0].payload.timestamp; + try { + const date = new Date(timestamp); + if (!isNaN(date.getTime())) { + return date.toLocaleString(); + } + } catch (error) { + console.error("Error parsing timestamp:", error); + } + } + return String(value); + }} + /> + } + /> + } /> + {selectedDataKeys.map((key) => { + return ( + + ); + })} + + +
+
+ {selectedDataKeys.map((key, index) => { + const value = integrals[index]; + const title = "Integral Value of " + getLabelFromDataKey(key) + " " + Number(value).toFixed(2); + return ; + })} +
+ {selectedDataKeys.map((key, index) => { + return ( +
+ {key} : {integrals[index]} +
+ ); + })} +
+ ); } From ba8f82894c43d5c041afa69d275d5d06992135c3 Mon Sep 17 00:00:00 2001 From: Anthony Zheng Date: Thu, 15 Jan 2026 21:39:54 -0500 Subject: [PATCH 4/6] fix: added prettier formatting --- components/pages/StatsGraphTab.tsx | 1848 +++++++++++++++------------- 1 file changed, 972 insertions(+), 876 deletions(-) diff --git a/components/pages/StatsGraphTab.tsx b/components/pages/StatsGraphTab.tsx index 5b826ad..ac1ddd8 100644 --- a/components/pages/StatsGraphTab.tsx +++ b/components/pages/StatsGraphTab.tsx @@ -2,31 +2,35 @@ import { TelemetryData } from "@/lib/types"; import { CartesianGrid, Line, LineChart, XAxis, YAxis, Legend } from "recharts"; import { DropdownMenuCheckboxItemProps } from "@radix-ui/react-dropdown-menu"; import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, - ChartLegend, - ChartLegendContent, + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, } from "@/components/ui/chart"; import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { generateSelectGroups, getValueFromPath, TELEMETRY_FIELD_CONFIG } from "@/lib/chart-config"; import { - getCustomValue, - calculateNetPower, - calculateMotorPower, - calculateTotalSolarPower, - calculateBatteryEnergyAh, - calculateBatterySOC, - calculateMotorPowerConsumption, - mapTelemetryData, + generateSelectGroups, + getValueFromPath, + TELEMETRY_FIELD_CONFIG, +} from "@/lib/chart-config"; +import { + getCustomValue, + calculateNetPower, + calculateMotorPower, + calculateTotalSolarPower, + calculateBatteryEnergyAh, + calculateBatterySOC, + calculateMotorPowerConsumption, + mapTelemetryData, } from "@/lib/telemetry-utils"; import { useEffect, useState } from "react"; import { Axis3D, ChevronDownIcon, Download } from "lucide-react"; @@ -35,42 +39,46 @@ import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { fetchTelemetryDataInRange } from "@/lib/db-utils"; import SimpleCard from "../telemetry/SimpleCard"; import { Telemetry } from "next/dist/telemetry/storage"; function integrateData(data: any, dataKey: string) { - let integral = 0; + let integral = 0; - for (let i = 0; i < data.length - 1; i++) { - const val1 = data[i][dataKey]; - const val2 = data[i + 1][dataKey]; + for (let i = 0; i < data.length - 1; i++) { + const val1 = data[i][dataKey]; + const val2 = data[i + 1][dataKey]; - if (val1 == null || val2 == null) { - continue; - } + if (val1 == null || val2 == null) { + continue; + } - let date1 = new Date(data[i + 1].timestamp); - let date2 = new Date(data[i].timestamp); - let changeX = date1.getTime() - date2.getTime(); + let date1 = new Date(data[i + 1].timestamp); + let date2 = new Date(data[i].timestamp); + let changeX = date1.getTime() - date2.getTime(); - let midVal = (val2 + val1) / 2; - integral += (midVal * changeX) / 1000; - } - console.log("PENIS"); - return integral; + let midVal = (val2 + val1) / 2; + integral += (midVal * changeX) / 1000; + } + console.log("PENIS"); + return integral; } // Configuration constant to enable/disable refresh interval @@ -78,848 +86,936 @@ const ENABLE_REFRESH_INTERVAL = false; // Helper function to convert any date to CDT (UTC-5) function toCDT(date: Date | string): Date { - const d = new Date(date); + const d = new Date(date); - // Always subtract 1 hour regardless of environment - let adjustedTime = new Date(d.getTime() - 1 * 60 * 60 * 1000); // subtract 1 hour + // Always subtract 1 hour regardless of environment + let adjustedTime = new Date(d.getTime() - 1 * 60 * 60 * 1000); // subtract 1 hour - // Check if we're in local development (not production) - const isLocal = - process.env.NODE_ENV === "development" || - (typeof window !== "undefined" && window.location.hostname === "localhost"); + // Check if we're in local development (not production) + const isLocal = + process.env.NODE_ENV === "development" || + (typeof window !== "undefined" && window.location.hostname === "localhost"); - // If local, subtract additional 4 hours to match production timezone - if (isLocal) { - adjustedTime = new Date(adjustedTime.getTime() - 4 * 60 * 60 * 1000); // subtract additional 4 hours - } + // If local, subtract additional 4 hours to match production timezone + if (isLocal) { + adjustedTime = new Date(adjustedTime.getTime() - 4 * 60 * 60 * 1000); // subtract additional 4 hours + } - return adjustedTime; + return adjustedTime; } // Helper function to get label from data key function getLabelFromDataKey(dataKey: string): string { - // Handle custom fields - switch (dataKey) { - case "net_power": - return "Net Power"; - case "motor_power": - return "Motor Power"; - case "total_solar_power": - return "Total Solar Power"; - case "mppt_sum": - return "Total MPPT Voltage Output"; - case "battery_energy_ah": - return "Battery Remaining Energy (Ah)"; - case "battery_soc": - return "Battery SOC (%)"; - case "motor_power_consumption": - return "Motor Power Consumption"; - } - - // Convert from database format (e.g., "battery_main_bat_v") to config format (e.g., "battery.main_bat_v") - // Find the first underscore to separate category from the rest - const firstUnderscoreIndex = dataKey.indexOf("_"); - if (firstUnderscoreIndex === -1) { - return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); - } - - const category = dataKey.substring(0, firstUnderscoreIndex); - const field = dataKey.substring(firstUnderscoreIndex + 1); - - if (category && field && TELEMETRY_FIELD_CONFIG[category]?.fields[field]) { - return TELEMETRY_FIELD_CONFIG[category].fields[field]; - } - - // Fallback to formatted version of the key - return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); + // Handle custom fields + switch (dataKey) { + case "net_power": + return "Net Power"; + case "motor_power": + return "Motor Power"; + case "total_solar_power": + return "Total Solar Power"; + case "mppt_sum": + return "Total MPPT Voltage Output"; + case "battery_energy_ah": + return "Battery Remaining Energy (Ah)"; + case "battery_soc": + return "Battery SOC (%)"; + case "motor_power_consumption": + return "Motor Power Consumption"; + } + + // Convert from database format (e.g., "battery_main_bat_v") to config format (e.g., "battery.main_bat_v") + // Find the first underscore to separate category from the rest + const firstUnderscoreIndex = dataKey.indexOf("_"); + if (firstUnderscoreIndex === -1) { + return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); + } + + const category = dataKey.substring(0, firstUnderscoreIndex); + const field = dataKey.substring(firstUnderscoreIndex + 1); + + if (category && field && TELEMETRY_FIELD_CONFIG[category]?.fields[field]) { + return TELEMETRY_FIELD_CONFIG[category].fields[field]; + } + + // Fallback to formatted version of the key + return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); } // Generate dynamic chart config based on selected data keys -function generateChartConfig(selectedDataKeys: string[], lineColors: string[]): ChartConfig { - const config: ChartConfig = {}; - - selectedDataKeys.forEach((key, index) => { - config[key] = { - label: getLabelFromDataKey(key), - color: lineColors[index % lineColors.length], - }; - }); - - return config; +function generateChartConfig( + selectedDataKeys: string[], + lineColors: string[], +): ChartConfig { + const config: ChartConfig = {}; + + selectedDataKeys.forEach((key, index) => { + config[key] = { + label: getLabelFromDataKey(key), + color: lineColors[index % lineColors.length], + }; + }); + + return config; } export default function StatsGraphTab() { - const [open1, setOpen1] = useState(false); - const [open2, setOpen2] = useState(false); - const [multiselectEnabled, setMultiselectEnabled] = useState(false); - const [selectedDataKeys, setSelectedDataKeys] = useState(["battery_main_bat_v"]); - const [chartData, setChartData] = useState([]); - const [startDate, setStartDate] = useState(() => { - return new Date("2025-07-04T01:00:00"); - }); - const [refreshInterval, setRefreshInterval] = useState(null); - const [endDate, setEndDate] = useState(() => { - return new Date("2025-07-06T01:00:00"); - }); - - const [lineColors] = useState(["#8884D8", "#D88884", "#84D888", "#884D88", "#88884D", "#4D8888"]); - - const [minYTrim, setMinYTrim] = useState(undefined); - const [maxYTrim, setMaxYTrim] = useState(undefined); - - // Validation function to check for errors - const getValidationError = () => { - // Validate data and dateData are not null - if (chartData === null) { - return ( -
-
-

Data Error

-

Chart data is null or invalid

-
-
- ); - } - - if (startDate === null || endDate === null) { - return ( -
-
-

Date Error

-

Date data is null or invalid

-
-
- ); - } - - // Iterate through all data points and check for null values - if (chartData.length > 0) { - const hasNullData = chartData.some((dataPoint) => { - if (dataPoint === null || dataPoint === undefined) { - return true; - } - // Check if any selected data key has null values - return selectedDataKeys.some((key) => { - const value = dataPoint[key]; - return value === null; - }); - }); - - if (hasNullData) { - return ( -
-
-

Data Error

-

Some data points contain null values

-
-
- ); - } - } - - return null; - }; - - const selectGroups = generateSelectGroups(); - - // CSV Export function - const exportToCSV = () => { - if (filteredChartData.length === 0) { - return; - } - - // Create CSV headers - const headers = ["timestamp", ...selectedDataKeys]; - const csvHeaders = headers - .map((header) => (header === "timestamp" ? "Timestamp" : getLabelFromDataKey(header))) - .join(","); - - // Create CSV rows - const csvRows = filteredChartData.map((dataPoint) => { - return headers - .map((header) => { - if (header === "timestamp") { - const date = new Date(dataPoint.timestamp); - return `"${date.toLocaleString()}"`; - } - const value = dataPoint[header]; - return value !== undefined && value !== null ? value : ""; - }) - .join(","); - }); - - // Combine headers and rows - const csvContent = [csvHeaders, ...csvRows].join("\n"); - - // Create and download file - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const link = document.createElement("a"); - const url = URL.createObjectURL(blob); - link.setAttribute("href", url); - - // Generate filename with date range - const startStr = startDate?.toLocaleDateString().replace(/\//g, "-") || "start"; - const endStr = endDate?.toLocaleDateString().replace(/\//g, "-") || "end"; - link.setAttribute("download", `telemetry-data-${startStr}-to-${endStr}.csv`); - - link.style.visibility = "hidden"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - - const handleValueChange = (values: string[]) => { - setSelectedDataKeys(values.map((v) => v.replace(".", "_"))); - }; - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - } - setintegrals(calcIntegrals); - }, [selectedDataKeys, chartData]); - - useEffect(() => { - if (selectedDataKeys.length > 0 && startDate && endDate) { - // Check if any custom fields are selected - const customFields = selectedDataKeys.filter( - ( - key - ): key is - | "net_power" - | "motor_power" - | "total_solar_power" - | "mppt_sum" - | "battery_energy_ah" - | "battery_soc" - | "motor_power_consumption" => - key === "net_power" || - key === "motor_power" || - key === "total_solar_power" || - key === "mppt_sum" || - key === "battery_energy_ah" || - key === "battery_soc" || - key === "motor_power_consumption" - ); - const regularFields = selectedDataKeys.filter( - (key) => - ![ - "net_power", - "motor_power", - "total_solar_power", - "mppt_sum", - "battery_energy_ah", - "battery_soc", - "motor_power_consumption", - ].includes(key) - ); - - if (customFields.length > 0) { - // If custom fields are selected, fetch all telemetry data - fetchTelemetryDataInRange(startDate, endDate).then((allData) => { - if (!allData || !Array.isArray(allData)) return; - - const processedData = (allData as TelemetryData[]) - .map((dataPoint: any) => { - const result: any = { - timestamp: toCDT(dataPoint.created_at || dataPoint.gps?.rx_time || new Date()), - }; - - // Add regular fields - regularFields.forEach((field) => { - const firstUnderscore = field.indexOf("_"); - if (firstUnderscore !== -1) { - const category = field.substring(0, firstUnderscore); - const fieldName = field.substring(firstUnderscore + 1); - const value = getValueFromPath(dataPoint, `${category}.${fieldName}`); - if (value !== undefined) { - result[field] = value; - } - } - }); - - // Calculate custom fields - customFields.forEach((field) => { - switch (field) { - case "net_power": - result[field] = calculateNetPower(dataPoint); - break; - case "motor_power": - result[field] = calculateMotorPower(dataPoint); - break; - case "total_solar_power": - result[field] = calculateTotalSolarPower(dataPoint); - break; - case "mppt_sum": - result[field] = - dataPoint.mppt1.output_v + - dataPoint.mppt2.output_v + - dataPoint.mppt3.output_v; - break; - case "net_power": - // Calculate battery power: voltage * current - // Only include if both voltage and current are non-zero - const voltage = dataPoint.battery?.main_bat_v; - const current = dataPoint.battery?.main_bat_c; - if (voltage && current) { - result[field] = voltage * current; - } else { - result[field] = 0; - } - break; - case "battery_energy_ah": - result[field] = calculateBatteryEnergyAh(dataPoint); - break; - case "battery_soc": - result[field] = calculateBatterySOC(dataPoint); - break; - case "motor_power_consumption": - const motorPowerConsumption = calculateMotorPowerConsumption(dataPoint); - result[field] = motorPowerConsumption; - break; - } - }); - - return result; - }) - .filter((dataPoint) => { - // Filter out data points where any selected custom field has a value of 0 or null - return !customFields.some((field) => dataPoint[field] === 0 || dataPoint[field] === null); - }); - - setChartData(processedData); - }); - } else { - // If no custom fields, use the original approach - Promise.all( - selectedDataKeys.map((key) => - fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ - key, - data, - })) - ) - ).then((results) => { - const mergedData: { [timestamp: string]: any } = {}; - results.forEach(({ key, data }) => { - data?.forEach((point: any) => { - const cdtTimestamp = toCDT(point.timestamp); - const ts = cdtTimestamp.toISOString(); - if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; - mergedData[ts][key] = point.value; - }); - }); - const mergedArray = Object.values(mergedData).sort( - (a: any, b: any) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); - setChartData(mergedArray); - }); - } - } - }, [selectedDataKeys, startDate, endDate]); - - useEffect(() => { - if (refreshInterval) { - clearInterval(refreshInterval); - } - - // Only set up refresh interval if enabled - if (!ENABLE_REFRESH_INTERVAL) { - return; - } - - const interval = setInterval(() => { - if (selectedDataKeys.length > 0 && startDate && endDate) { - // Check if any custom fields are selected - const customFields = selectedDataKeys.filter( - ( - key - ): key is - | "net_power" - | "motor_power" - | "total_solar_power" - | "mppt_sum" - | "battery_energy_ah" - | "battery_soc" - | "motor_power_consumption" => - key === "net_power" || - key === "motor_power" || - key === "total_solar_power" || - key === "mppt_sum" || - key === "battery_energy_ah" || - key === "battery_soc" || - key === "motor_power_consumption" - ); - const regularFields = selectedDataKeys.filter( - (key) => - ![ - "net_power", - "motor_power", - "total_solar_power", - "mppt_sum", - "battery_energy_ah", - "battery_soc", - "motor_power_consumption", - ].includes(key) - ); - - if (customFields.length > 0) { - // If custom fields are selected, fetch all telemetry data - fetchTelemetryDataInRange(startDate, endDate).then((allData) => { - if (!allData || !Array.isArray(allData)) return; - - const processedData = (allData as TelemetryData[]) - .map((dataPoint: any) => { - const result: any = { - timestamp: toCDT(dataPoint.created_at || dataPoint.gps?.rx_time || new Date()), - }; - - // Add regular fields - regularFields.forEach((field) => { - const firstUnderscore = field.indexOf("_"); - if (firstUnderscore !== -1) { - const category = field.substring(0, firstUnderscore); - const fieldName = field.substring(firstUnderscore + 1); - const value = getValueFromPath(dataPoint, `${category}.${fieldName}`); - if (value !== undefined) { - result[field] = value; - } - } - }); - - // Calculate custom fields - customFields.forEach((field) => { - switch (field) { - case "net_power": - result[field] = calculateNetPower(dataPoint); - break; - case "motor_power": - result[field] = calculateMotorPower(dataPoint); - break; - case "total_solar_power": - result[field] = calculateTotalSolarPower(dataPoint); - break; - case "mppt_sum": - result[field] = - dataPoint.mppt1.output_v + - dataPoint.mppt2.output_v + - dataPoint.mppt3.output_v; - break; - case "net_power": - // Calculate battery power: voltage * current - // Only include if both voltage and current are non-zero - const voltage = dataPoint.battery?.main_bat_v; - const current = dataPoint.battery?.main_bat_c; - if (voltage && current) { - result[field] = voltage * current; - } else { - result[field] = 0; - } - break; - case "battery_energy_ah": - result[field] = calculateBatteryEnergyAh(dataPoint); - break; - case "battery_soc": - result[field] = calculateBatterySOC(dataPoint); - break; - case "motor_power_consumption": - const motorPowerConsumption = calculateMotorPowerConsumption(dataPoint); - result[field] = motorPowerConsumption; - break; - } - }); - - return result; - }) - .filter((dataPoint) => { - // Filter out data points where any selected custom field has a value of 0 or null - return !customFields.some( - (field) => dataPoint[field] === 0 || dataPoint[field] === null - ); - }); - - setChartData(processedData); - }); - } else { - // If no custom fields, use the original approach - Promise.all( - selectedDataKeys.map((key) => - fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ - key, - data, - })) - ) - ).then((results) => { - const mergedData: { [timestamp: string]: any } = {}; - results.forEach(({ key, data }) => { - data?.forEach((point: any) => { - const cdtTimestamp = toCDT(point.timestamp); - const ts = cdtTimestamp.toISOString(); - if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; - mergedData[ts][key] = point.value; - }); - }); - const mergedArray = Object.values(mergedData).sort( - (a: any, b: any) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); - setChartData(mergedArray); - }); - } - } - }, 2000); - setRefreshInterval(interval); - - return () => clearInterval(interval); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDataKeys, startDate, endDate]); - - // Filter chart data based on Y trim values - const filteredChartData = chartData.filter((dataPoint) => { - // Check if any of the selected data keys have values outside the trim range - for (const key of selectedDataKeys) { - const value = dataPoint[key]; - if (value !== undefined && value !== null) { - if (minYTrim !== undefined && value < minYTrim) return false; - if (maxYTrim !== undefined && value > maxYTrim) return false; - } - } - return true; - }); - - // Generate dynamic chart - const chartConfig = generateChartConfig(selectedDataKeys, lineColors); - - // Check for validation errors and return early if any exist - const validationError = getValidationError(); - if (validationError) { - return validationError; - } - - const [integrals, setintegrals] = useState([]); - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - setintegrals(calcIntegrals); - } - }, [chartData]); - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - setintegrals(calcIntegrals); - } - }, [chartData]); - - return ( -
-
-
- - - - - - {selectGroups.map((group) => ( -
- {group.label} - - {group.options.map((option) => ( - { - const value = option.value.replace(".", "_"); - let updatedKeys: string[]; - - if (multiselectEnabled) { - // Multiselect mode: add/remove from array - updatedKeys = checked - ? [...selectedDataKeys, value] - : selectedDataKeys.filter((key) => key !== value); - } else { - // Single select mode: replace selection - updatedKeys = checked ? [value] : []; - } - - handleValueChange(updatedKeys); - }} - > - {option.label} - - ))} -
- ))} -
-
-
- { - setMultiselectEnabled(checked as boolean); - // If disabling multiselect and multiple items are selected, keep only the first one - if (!checked && selectedDataKeys.length > 1) { - setSelectedDataKeys([selectedDataKeys[0]]); - } - }} - /> - -
-
-
-
- - - - - - { - setStartDate(date); - setOpen1(false); - }} - /> - - -
-
- { - if (startDate) { - const newDate = new Date(startDate); - const [hours, minutes] = e.target.value.split(":"); - newDate.setHours(parseInt(hours)); - newDate.setMinutes(parseInt(minutes)); - setStartDate(newDate); - } - }} - className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" - /> -
-
-
-
- - - - - - { - setEndDate(date); - setOpen2(false); - }} - /> - - -
-
- { - if (endDate) { - const newDate = new Date(endDate); - const [hours, minutes] = e.target.value.split(":"); - newDate.setHours(parseInt(hours)); - newDate.setMinutes(parseInt(minutes)); - setEndDate(newDate); - } - }} - className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" - /> -
-
-
- - {/* Y-Axis Trim Controls and Export */} -
-
- - { - const value = e.target.value; - setMinYTrim(value === "" ? undefined : parseFloat(value)); - }} - className="w-32" - /> -
-
- - { - const value = e.target.value; - setMaxYTrim(value === "" ? undefined : parseFloat(value)); - }} - className="w-32" - /> -
-
- - - - - - - - Export Telemetry Data - - This will download a CSV file containing {filteredChartData.length} data points for - the selected fields from {startDate?.toLocaleDateString()} to{" "} - {endDate?.toLocaleDateString()}. - {selectedDataKeys.length > 0 && ( - - Selected fields:{" "} - {selectedDataKeys.map((key) => getLabelFromDataKey(key)).join(", ")} - - )} - - - - Cancel - Download CSV - - - -
-
- -

- - - - { - const date = value instanceof Date ? value : new Date(value); - return date.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - }} - /> - Number.parseFloat(value.toFixed(1)).toString()} - /> - { - // Get the timestamp from the payload data - if (payload && payload.length > 0 && payload[0].payload) { - const timestamp = payload[0].payload.timestamp; - try { - const date = new Date(timestamp); - if (!isNaN(date.getTime())) { - return date.toLocaleString(); - } - } catch (error) { - console.error("Error parsing timestamp:", error); - } - } - return String(value); - }} - /> - } - /> - } /> - {selectedDataKeys.map((key) => { - return ( - - ); - })} - - -
-
- {selectedDataKeys.map((key, index) => { - const value = integrals[index]; - const title = "Integral Value of " + getLabelFromDataKey(key) + " " + Number(value).toFixed(2); - return ; - })} -
- {selectedDataKeys.map((key, index) => { - return ( -
- {key} : {integrals[index]} -
- ); - })} -
- ); + const [open1, setOpen1] = useState(false); + const [open2, setOpen2] = useState(false); + const [multiselectEnabled, setMultiselectEnabled] = useState(false); + const [selectedDataKeys, setSelectedDataKeys] = useState([ + "battery_main_bat_v", + ]); + const [chartData, setChartData] = useState([]); + const [startDate, setStartDate] = useState(() => { + return new Date("2025-07-04T01:00:00"); + }); + const [refreshInterval, setRefreshInterval] = useState( + null, + ); + const [endDate, setEndDate] = useState(() => { + return new Date("2025-07-06T01:00:00"); + }); + + const [lineColors] = useState([ + "#8884D8", + "#D88884", + "#84D888", + "#884D88", + "#88884D", + "#4D8888", + ]); + + const [minYTrim, setMinYTrim] = useState(undefined); + const [maxYTrim, setMaxYTrim] = useState(undefined); + + // Validation function to check for errors + const getValidationError = () => { + // Validate data and dateData are not null + if (chartData === null) { + return ( +
+
+

Data Error

+

+ Chart data is null or invalid +

+
+
+ ); + } + + if (startDate === null || endDate === null) { + return ( +
+
+

Date Error

+

+ Date data is null or invalid +

+
+
+ ); + } + + // Iterate through all data points and check for null values + if (chartData.length > 0) { + const hasNullData = chartData.some((dataPoint) => { + if (dataPoint === null || dataPoint === undefined) { + return true; + } + // Check if any selected data key has null values + return selectedDataKeys.some((key) => { + const value = dataPoint[key]; + return value === null; + }); + }); + + if (hasNullData) { + return ( +
+
+

Data Error

+

+ Some data points contain null values +

+
+
+ ); + } + } + + return null; + }; + + const selectGroups = generateSelectGroups(); + + // CSV Export function + const exportToCSV = () => { + if (filteredChartData.length === 0) { + return; + } + + // Create CSV headers + const headers = ["timestamp", ...selectedDataKeys]; + const csvHeaders = headers + .map((header) => + header === "timestamp" ? "Timestamp" : getLabelFromDataKey(header), + ) + .join(","); + + // Create CSV rows + const csvRows = filteredChartData.map((dataPoint) => { + return headers + .map((header) => { + if (header === "timestamp") { + const date = new Date(dataPoint.timestamp); + return `"${date.toLocaleString()}"`; + } + const value = dataPoint[header]; + return value !== undefined && value !== null ? value : ""; + }) + .join(","); + }); + + // Combine headers and rows + const csvContent = [csvHeaders, ...csvRows].join("\n"); + + // Create and download file + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + + // Generate filename with date range + const startStr = + startDate?.toLocaleDateString().replace(/\//g, "-") || "start"; + const endStr = endDate?.toLocaleDateString().replace(/\//g, "-") || "end"; + link.setAttribute( + "download", + `telemetry-data-${startStr}-to-${endStr}.csv`, + ); + + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleValueChange = (values: string[]) => { + setSelectedDataKeys(values.map((v) => v.replace(".", "_"))); + }; + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + } + setintegrals(calcIntegrals); + }, [selectedDataKeys, chartData]); + + useEffect(() => { + if (selectedDataKeys.length > 0 && startDate && endDate) { + // Check if any custom fields are selected + const customFields = selectedDataKeys.filter( + ( + key, + ): key is + | "net_power" + | "motor_power" + | "total_solar_power" + | "mppt_sum" + | "battery_energy_ah" + | "battery_soc" + | "motor_power_consumption" => + key === "net_power" || + key === "motor_power" || + key === "total_solar_power" || + key === "mppt_sum" || + key === "battery_energy_ah" || + key === "battery_soc" || + key === "motor_power_consumption", + ); + const regularFields = selectedDataKeys.filter( + (key) => + ![ + "net_power", + "motor_power", + "total_solar_power", + "mppt_sum", + "battery_energy_ah", + "battery_soc", + "motor_power_consumption", + ].includes(key), + ); + + if (customFields.length > 0) { + // If custom fields are selected, fetch all telemetry data + fetchTelemetryDataInRange(startDate, endDate).then((allData) => { + if (!allData || !Array.isArray(allData)) return; + + const processedData = (allData as TelemetryData[]) + .map((dataPoint: any) => { + const result: any = { + timestamp: toCDT( + dataPoint.created_at || dataPoint.gps?.rx_time || new Date(), + ), + }; + + // Add regular fields + regularFields.forEach((field) => { + const firstUnderscore = field.indexOf("_"); + if (firstUnderscore !== -1) { + const category = field.substring(0, firstUnderscore); + const fieldName = field.substring(firstUnderscore + 1); + const value = getValueFromPath( + dataPoint, + `${category}.${fieldName}`, + ); + if (value !== undefined) { + result[field] = value; + } + } + }); + + // Calculate custom fields + customFields.forEach((field) => { + switch (field) { + case "net_power": + result[field] = calculateNetPower(dataPoint); + break; + case "motor_power": + result[field] = calculateMotorPower(dataPoint); + break; + case "total_solar_power": + result[field] = calculateTotalSolarPower(dataPoint); + break; + case "mppt_sum": + result[field] = + dataPoint.mppt1.output_v + + dataPoint.mppt2.output_v + + dataPoint.mppt3.output_v; + break; + case "net_power": + // Calculate battery power: voltage * current + // Only include if both voltage and current are non-zero + const voltage = dataPoint.battery?.main_bat_v; + const current = dataPoint.battery?.main_bat_c; + if (voltage && current) { + result[field] = voltage * current; + } else { + result[field] = 0; + } + break; + case "battery_energy_ah": + result[field] = calculateBatteryEnergyAh(dataPoint); + break; + case "battery_soc": + result[field] = calculateBatterySOC(dataPoint); + break; + case "motor_power_consumption": + const motorPowerConsumption = + calculateMotorPowerConsumption(dataPoint); + result[field] = motorPowerConsumption; + break; + } + }); + + return result; + }) + .filter((dataPoint) => { + // Filter out data points where any selected custom field has a value of 0 or null + return !customFields.some( + (field) => dataPoint[field] === 0 || dataPoint[field] === null, + ); + }); + + setChartData(processedData); + }); + } else { + // If no custom fields, use the original approach + Promise.all( + selectedDataKeys.map((key) => + fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ + key, + data, + })), + ), + ).then((results) => { + const mergedData: { [timestamp: string]: any } = {}; + results.forEach(({ key, data }) => { + data?.forEach((point: any) => { + const cdtTimestamp = toCDT(point.timestamp); + const ts = cdtTimestamp.toISOString(); + if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; + mergedData[ts][key] = point.value; + }); + }); + const mergedArray = Object.values(mergedData).sort( + (a: any, b: any) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + setChartData(mergedArray); + }); + } + } + }, [selectedDataKeys, startDate, endDate]); + + useEffect(() => { + if (refreshInterval) { + clearInterval(refreshInterval); + } + + // Only set up refresh interval if enabled + if (!ENABLE_REFRESH_INTERVAL) { + return; + } + + const interval = setInterval(() => { + if (selectedDataKeys.length > 0 && startDate && endDate) { + // Check if any custom fields are selected + const customFields = selectedDataKeys.filter( + ( + key, + ): key is + | "net_power" + | "motor_power" + | "total_solar_power" + | "mppt_sum" + | "battery_energy_ah" + | "battery_soc" + | "motor_power_consumption" => + key === "net_power" || + key === "motor_power" || + key === "total_solar_power" || + key === "mppt_sum" || + key === "battery_energy_ah" || + key === "battery_soc" || + key === "motor_power_consumption", + ); + const regularFields = selectedDataKeys.filter( + (key) => + ![ + "net_power", + "motor_power", + "total_solar_power", + "mppt_sum", + "battery_energy_ah", + "battery_soc", + "motor_power_consumption", + ].includes(key), + ); + + if (customFields.length > 0) { + // If custom fields are selected, fetch all telemetry data + fetchTelemetryDataInRange(startDate, endDate).then((allData) => { + if (!allData || !Array.isArray(allData)) return; + + const processedData = (allData as TelemetryData[]) + .map((dataPoint: any) => { + const result: any = { + timestamp: toCDT( + dataPoint.created_at || + dataPoint.gps?.rx_time || + new Date(), + ), + }; + + // Add regular fields + regularFields.forEach((field) => { + const firstUnderscore = field.indexOf("_"); + if (firstUnderscore !== -1) { + const category = field.substring(0, firstUnderscore); + const fieldName = field.substring(firstUnderscore + 1); + const value = getValueFromPath( + dataPoint, + `${category}.${fieldName}`, + ); + if (value !== undefined) { + result[field] = value; + } + } + }); + + // Calculate custom fields + customFields.forEach((field) => { + switch (field) { + case "net_power": + result[field] = calculateNetPower(dataPoint); + break; + case "motor_power": + result[field] = calculateMotorPower(dataPoint); + break; + case "total_solar_power": + result[field] = calculateTotalSolarPower(dataPoint); + break; + case "mppt_sum": + result[field] = + dataPoint.mppt1.output_v + + dataPoint.mppt2.output_v + + dataPoint.mppt3.output_v; + break; + case "net_power": + // Calculate battery power: voltage * current + // Only include if both voltage and current are non-zero + const voltage = dataPoint.battery?.main_bat_v; + const current = dataPoint.battery?.main_bat_c; + if (voltage && current) { + result[field] = voltage * current; + } else { + result[field] = 0; + } + break; + case "battery_energy_ah": + result[field] = calculateBatteryEnergyAh(dataPoint); + break; + case "battery_soc": + result[field] = calculateBatterySOC(dataPoint); + break; + case "motor_power_consumption": + const motorPowerConsumption = + calculateMotorPowerConsumption(dataPoint); + result[field] = motorPowerConsumption; + break; + } + }); + + return result; + }) + .filter((dataPoint) => { + // Filter out data points where any selected custom field has a value of 0 or null + return !customFields.some( + (field) => + dataPoint[field] === 0 || dataPoint[field] === null, + ); + }); + + setChartData(processedData); + }); + } else { + // If no custom fields, use the original approach + Promise.all( + selectedDataKeys.map((key) => + fetchTelemetryDataInRange(startDate, endDate, key).then( + (data) => ({ + key, + data, + }), + ), + ), + ).then((results) => { + const mergedData: { [timestamp: string]: any } = {}; + results.forEach(({ key, data }) => { + data?.forEach((point: any) => { + const cdtTimestamp = toCDT(point.timestamp); + const ts = cdtTimestamp.toISOString(); + if (!mergedData[ts]) + mergedData[ts] = { timestamp: cdtTimestamp }; + mergedData[ts][key] = point.value; + }); + }); + const mergedArray = Object.values(mergedData).sort( + (a: any, b: any) => + new Date(a.timestamp).getTime() - + new Date(b.timestamp).getTime(), + ); + setChartData(mergedArray); + }); + } + } + }, 2000); + setRefreshInterval(interval); + + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDataKeys, startDate, endDate]); + + // Filter chart data based on Y trim values + const filteredChartData = chartData.filter((dataPoint) => { + // Check if any of the selected data keys have values outside the trim range + for (const key of selectedDataKeys) { + const value = dataPoint[key]; + if (value !== undefined && value !== null) { + if (minYTrim !== undefined && value < minYTrim) return false; + if (maxYTrim !== undefined && value > maxYTrim) return false; + } + } + return true; + }); + + // Generate dynamic chart + const chartConfig = generateChartConfig(selectedDataKeys, lineColors); + + // Check for validation errors and return early if any exist + const validationError = getValidationError(); + if (validationError) { + return validationError; + } + + const [integrals, setintegrals] = useState([]); + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + setintegrals(calcIntegrals); + } + }, [chartData]); + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + setintegrals(calcIntegrals); + } + }, [chartData]); + + return ( +
+
+
+ + + + + + {selectGroups.map((group) => ( +
+ {group.label} + + {group.options.map((option) => ( + { + const value = option.value.replace(".", "_"); + let updatedKeys: string[]; + + if (multiselectEnabled) { + // Multiselect mode: add/remove from array + updatedKeys = checked + ? [...selectedDataKeys, value] + : selectedDataKeys.filter((key) => key !== value); + } else { + // Single select mode: replace selection + updatedKeys = checked ? [value] : []; + } + + handleValueChange(updatedKeys); + }} + > + {option.label} + + ))} +
+ ))} +
+
+
+ { + setMultiselectEnabled(checked as boolean); + // If disabling multiselect and multiple items are selected, keep only the first one + if (!checked && selectedDataKeys.length > 1) { + setSelectedDataKeys([selectedDataKeys[0]]); + } + }} + /> + +
+
+
+
+ + + + + + { + setStartDate(date); + setOpen1(false); + }} + /> + + +
+
+ { + if (startDate) { + const newDate = new Date(startDate); + const [hours, minutes] = e.target.value.split(":"); + newDate.setHours(parseInt(hours)); + newDate.setMinutes(parseInt(minutes)); + setStartDate(newDate); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + /> +
+
+
+
+ + + + + + { + setEndDate(date); + setOpen2(false); + }} + /> + + +
+
+ { + if (endDate) { + const newDate = new Date(endDate); + const [hours, minutes] = e.target.value.split(":"); + newDate.setHours(parseInt(hours)); + newDate.setMinutes(parseInt(minutes)); + setEndDate(newDate); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + /> +
+
+
+ + {/* Y-Axis Trim Controls and Export */} +
+
+ + { + const value = e.target.value; + setMinYTrim(value === "" ? undefined : parseFloat(value)); + }} + className="w-32" + /> +
+
+ + { + const value = e.target.value; + setMaxYTrim(value === "" ? undefined : parseFloat(value)); + }} + className="w-32" + /> +
+
+ + + + + + + + Export Telemetry Data + + This will download a CSV file containing{" "} + {filteredChartData.length} data points for the selected fields + from {startDate?.toLocaleDateString()} to{" "} + {endDate?.toLocaleDateString()}. + {selectedDataKeys.length > 0 && ( + + Selected fields:{" "} + {selectedDataKeys + .map((key) => getLabelFromDataKey(key)) + .join(", ")} + + )} + + + + Cancel + + Download CSV + + + + +
+
+ +

+ + + + { + const date = value instanceof Date ? value : new Date(value); + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + }} + /> + + Number.parseFloat(value.toFixed(1)).toString() + } + /> + { + // Get the timestamp from the payload data + if (payload && payload.length > 0 && payload[0].payload) { + const timestamp = payload[0].payload.timestamp; + try { + const date = new Date(timestamp); + if (!isNaN(date.getTime())) { + return date.toLocaleString(); + } + } catch (error) { + console.error("Error parsing timestamp:", error); + } + } + return String(value); + }} + /> + } + /> + } /> + {selectedDataKeys.map((key) => { + return ( + + ); + })} + + +
+
+ {selectedDataKeys.map((key, index) => { + const value = integrals[index]; + const title = + "Integral Value of " + + getLabelFromDataKey(key) + + " " + + Number(value).toFixed(2); + return ( + + ); + })} +
+ {selectedDataKeys.map((key, index) => { + return ( +
+ {key} : {integrals[index]} +
+ ); + })} +
+ ); } From 15659c43ce1b23f99da80a5955faa3a854a27141 Mon Sep 17 00:00:00 2001 From: Anthony Zheng Date: Thu, 15 Jan 2026 21:52:41 -0500 Subject: [PATCH 5/6] removed unused imports --- components/pages/StatsGraphTab.tsx | 1849 +++++++++++++--------------- 1 file changed, 875 insertions(+), 974 deletions(-) diff --git a/components/pages/StatsGraphTab.tsx b/components/pages/StatsGraphTab.tsx index ac1ddd8..a852b63 100644 --- a/components/pages/StatsGraphTab.tsx +++ b/components/pages/StatsGraphTab.tsx @@ -1,36 +1,31 @@ import { TelemetryData } from "@/lib/types"; import { CartesianGrid, Line, LineChart, XAxis, YAxis, Legend } from "recharts"; -import { DropdownMenuCheckboxItemProps } from "@radix-ui/react-dropdown-menu"; import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, - ChartLegend, - ChartLegendContent, + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, } from "@/components/ui/chart"; import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { generateSelectGroups, getValueFromPath, TELEMETRY_FIELD_CONFIG } from "@/lib/chart-config"; import { - generateSelectGroups, - getValueFromPath, - TELEMETRY_FIELD_CONFIG, -} from "@/lib/chart-config"; -import { - getCustomValue, - calculateNetPower, - calculateMotorPower, - calculateTotalSolarPower, - calculateBatteryEnergyAh, - calculateBatterySOC, - calculateMotorPowerConsumption, - mapTelemetryData, + getCustomValue, + calculateNetPower, + calculateMotorPower, + calculateTotalSolarPower, + calculateBatteryEnergyAh, + calculateBatterySOC, + calculateMotorPowerConsumption, + mapTelemetryData, } from "@/lib/telemetry-utils"; import { useEffect, useState } from "react"; import { Axis3D, ChevronDownIcon, Download } from "lucide-react"; @@ -39,46 +34,40 @@ import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { fetchTelemetryDataInRange } from "@/lib/db-utils"; import SimpleCard from "../telemetry/SimpleCard"; -import { Telemetry } from "next/dist/telemetry/storage"; function integrateData(data: any, dataKey: string) { - let integral = 0; + let integral = 0; - for (let i = 0; i < data.length - 1; i++) { - const val1 = data[i][dataKey]; - const val2 = data[i + 1][dataKey]; + for (let i = 0; i < data.length - 1; i++) { + const val1 = data[i][dataKey]; + const val2 = data[i + 1][dataKey]; - if (val1 == null || val2 == null) { - continue; - } + if (val1 == null || val2 == null) { + continue; + } - let date1 = new Date(data[i + 1].timestamp); - let date2 = new Date(data[i].timestamp); - let changeX = date1.getTime() - date2.getTime(); + let date1 = new Date(data[i + 1].timestamp); + let date2 = new Date(data[i].timestamp); + let changeX = date1.getTime() - date2.getTime(); - let midVal = (val2 + val1) / 2; - integral += (midVal * changeX) / 1000; - } - console.log("PENIS"); - return integral; + let midVal = (val2 + val1) / 2; + integral += (midVal * changeX) / 1000; + } + return integral; } // Configuration constant to enable/disable refresh interval @@ -86,936 +75,848 @@ const ENABLE_REFRESH_INTERVAL = false; // Helper function to convert any date to CDT (UTC-5) function toCDT(date: Date | string): Date { - const d = new Date(date); + const d = new Date(date); - // Always subtract 1 hour regardless of environment - let adjustedTime = new Date(d.getTime() - 1 * 60 * 60 * 1000); // subtract 1 hour + // Always subtract 1 hour regardless of environment + let adjustedTime = new Date(d.getTime() - 1 * 60 * 60 * 1000); // subtract 1 hour - // Check if we're in local development (not production) - const isLocal = - process.env.NODE_ENV === "development" || - (typeof window !== "undefined" && window.location.hostname === "localhost"); + // Check if we're in local development (not production) + const isLocal = + process.env.NODE_ENV === "development" || + (typeof window !== "undefined" && window.location.hostname === "localhost"); - // If local, subtract additional 4 hours to match production timezone - if (isLocal) { - adjustedTime = new Date(adjustedTime.getTime() - 4 * 60 * 60 * 1000); // subtract additional 4 hours - } + // If local, subtract additional 4 hours to match production timezone + if (isLocal) { + adjustedTime = new Date(adjustedTime.getTime() - 4 * 60 * 60 * 1000); // subtract additional 4 hours + } - return adjustedTime; + return adjustedTime; } // Helper function to get label from data key function getLabelFromDataKey(dataKey: string): string { - // Handle custom fields - switch (dataKey) { - case "net_power": - return "Net Power"; - case "motor_power": - return "Motor Power"; - case "total_solar_power": - return "Total Solar Power"; - case "mppt_sum": - return "Total MPPT Voltage Output"; - case "battery_energy_ah": - return "Battery Remaining Energy (Ah)"; - case "battery_soc": - return "Battery SOC (%)"; - case "motor_power_consumption": - return "Motor Power Consumption"; - } - - // Convert from database format (e.g., "battery_main_bat_v") to config format (e.g., "battery.main_bat_v") - // Find the first underscore to separate category from the rest - const firstUnderscoreIndex = dataKey.indexOf("_"); - if (firstUnderscoreIndex === -1) { - return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); - } - - const category = dataKey.substring(0, firstUnderscoreIndex); - const field = dataKey.substring(firstUnderscoreIndex + 1); - - if (category && field && TELEMETRY_FIELD_CONFIG[category]?.fields[field]) { - return TELEMETRY_FIELD_CONFIG[category].fields[field]; - } - - // Fallback to formatted version of the key - return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); + // Handle custom fields + switch (dataKey) { + case "net_power": + return "Net Power"; + case "motor_power": + return "Motor Power"; + case "total_solar_power": + return "Total Solar Power"; + case "mppt_sum": + return "Total MPPT Voltage Output"; + case "battery_energy_ah": + return "Battery Remaining Energy (Ah)"; + case "battery_soc": + return "Battery SOC (%)"; + case "motor_power_consumption": + return "Motor Power Consumption"; + } + + // Convert from database format (e.g., "battery_main_bat_v") to config format (e.g., "battery.main_bat_v") + // Find the first underscore to separate category from the rest + const firstUnderscoreIndex = dataKey.indexOf("_"); + if (firstUnderscoreIndex === -1) { + return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); + } + + const category = dataKey.substring(0, firstUnderscoreIndex); + const field = dataKey.substring(firstUnderscoreIndex + 1); + + if (category && field && TELEMETRY_FIELD_CONFIG[category]?.fields[field]) { + return TELEMETRY_FIELD_CONFIG[category].fields[field]; + } + + // Fallback to formatted version of the key + return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); } // Generate dynamic chart config based on selected data keys -function generateChartConfig( - selectedDataKeys: string[], - lineColors: string[], -): ChartConfig { - const config: ChartConfig = {}; - - selectedDataKeys.forEach((key, index) => { - config[key] = { - label: getLabelFromDataKey(key), - color: lineColors[index % lineColors.length], - }; - }); - - return config; +function generateChartConfig(selectedDataKeys: string[], lineColors: string[]): ChartConfig { + const config: ChartConfig = {}; + + selectedDataKeys.forEach((key, index) => { + config[key] = { + label: getLabelFromDataKey(key), + color: lineColors[index % lineColors.length], + }; + }); + + return config; } export default function StatsGraphTab() { - const [open1, setOpen1] = useState(false); - const [open2, setOpen2] = useState(false); - const [multiselectEnabled, setMultiselectEnabled] = useState(false); - const [selectedDataKeys, setSelectedDataKeys] = useState([ - "battery_main_bat_v", - ]); - const [chartData, setChartData] = useState([]); - const [startDate, setStartDate] = useState(() => { - return new Date("2025-07-04T01:00:00"); - }); - const [refreshInterval, setRefreshInterval] = useState( - null, - ); - const [endDate, setEndDate] = useState(() => { - return new Date("2025-07-06T01:00:00"); - }); - - const [lineColors] = useState([ - "#8884D8", - "#D88884", - "#84D888", - "#884D88", - "#88884D", - "#4D8888", - ]); - - const [minYTrim, setMinYTrim] = useState(undefined); - const [maxYTrim, setMaxYTrim] = useState(undefined); - - // Validation function to check for errors - const getValidationError = () => { - // Validate data and dateData are not null - if (chartData === null) { - return ( -
-
-

Data Error

-

- Chart data is null or invalid -

-
-
- ); - } - - if (startDate === null || endDate === null) { - return ( -
-
-

Date Error

-

- Date data is null or invalid -

-
-
- ); - } - - // Iterate through all data points and check for null values - if (chartData.length > 0) { - const hasNullData = chartData.some((dataPoint) => { - if (dataPoint === null || dataPoint === undefined) { - return true; - } - // Check if any selected data key has null values - return selectedDataKeys.some((key) => { - const value = dataPoint[key]; - return value === null; - }); - }); - - if (hasNullData) { - return ( -
-
-

Data Error

-

- Some data points contain null values -

-
-
- ); - } - } - - return null; - }; - - const selectGroups = generateSelectGroups(); - - // CSV Export function - const exportToCSV = () => { - if (filteredChartData.length === 0) { - return; - } - - // Create CSV headers - const headers = ["timestamp", ...selectedDataKeys]; - const csvHeaders = headers - .map((header) => - header === "timestamp" ? "Timestamp" : getLabelFromDataKey(header), - ) - .join(","); - - // Create CSV rows - const csvRows = filteredChartData.map((dataPoint) => { - return headers - .map((header) => { - if (header === "timestamp") { - const date = new Date(dataPoint.timestamp); - return `"${date.toLocaleString()}"`; - } - const value = dataPoint[header]; - return value !== undefined && value !== null ? value : ""; - }) - .join(","); - }); - - // Combine headers and rows - const csvContent = [csvHeaders, ...csvRows].join("\n"); - - // Create and download file - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const link = document.createElement("a"); - const url = URL.createObjectURL(blob); - link.setAttribute("href", url); - - // Generate filename with date range - const startStr = - startDate?.toLocaleDateString().replace(/\//g, "-") || "start"; - const endStr = endDate?.toLocaleDateString().replace(/\//g, "-") || "end"; - link.setAttribute( - "download", - `telemetry-data-${startStr}-to-${endStr}.csv`, - ); - - link.style.visibility = "hidden"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - - const handleValueChange = (values: string[]) => { - setSelectedDataKeys(values.map((v) => v.replace(".", "_"))); - }; - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - } - setintegrals(calcIntegrals); - }, [selectedDataKeys, chartData]); - - useEffect(() => { - if (selectedDataKeys.length > 0 && startDate && endDate) { - // Check if any custom fields are selected - const customFields = selectedDataKeys.filter( - ( - key, - ): key is - | "net_power" - | "motor_power" - | "total_solar_power" - | "mppt_sum" - | "battery_energy_ah" - | "battery_soc" - | "motor_power_consumption" => - key === "net_power" || - key === "motor_power" || - key === "total_solar_power" || - key === "mppt_sum" || - key === "battery_energy_ah" || - key === "battery_soc" || - key === "motor_power_consumption", - ); - const regularFields = selectedDataKeys.filter( - (key) => - ![ - "net_power", - "motor_power", - "total_solar_power", - "mppt_sum", - "battery_energy_ah", - "battery_soc", - "motor_power_consumption", - ].includes(key), - ); - - if (customFields.length > 0) { - // If custom fields are selected, fetch all telemetry data - fetchTelemetryDataInRange(startDate, endDate).then((allData) => { - if (!allData || !Array.isArray(allData)) return; - - const processedData = (allData as TelemetryData[]) - .map((dataPoint: any) => { - const result: any = { - timestamp: toCDT( - dataPoint.created_at || dataPoint.gps?.rx_time || new Date(), - ), - }; - - // Add regular fields - regularFields.forEach((field) => { - const firstUnderscore = field.indexOf("_"); - if (firstUnderscore !== -1) { - const category = field.substring(0, firstUnderscore); - const fieldName = field.substring(firstUnderscore + 1); - const value = getValueFromPath( - dataPoint, - `${category}.${fieldName}`, - ); - if (value !== undefined) { - result[field] = value; - } - } - }); - - // Calculate custom fields - customFields.forEach((field) => { - switch (field) { - case "net_power": - result[field] = calculateNetPower(dataPoint); - break; - case "motor_power": - result[field] = calculateMotorPower(dataPoint); - break; - case "total_solar_power": - result[field] = calculateTotalSolarPower(dataPoint); - break; - case "mppt_sum": - result[field] = - dataPoint.mppt1.output_v + - dataPoint.mppt2.output_v + - dataPoint.mppt3.output_v; - break; - case "net_power": - // Calculate battery power: voltage * current - // Only include if both voltage and current are non-zero - const voltage = dataPoint.battery?.main_bat_v; - const current = dataPoint.battery?.main_bat_c; - if (voltage && current) { - result[field] = voltage * current; - } else { - result[field] = 0; - } - break; - case "battery_energy_ah": - result[field] = calculateBatteryEnergyAh(dataPoint); - break; - case "battery_soc": - result[field] = calculateBatterySOC(dataPoint); - break; - case "motor_power_consumption": - const motorPowerConsumption = - calculateMotorPowerConsumption(dataPoint); - result[field] = motorPowerConsumption; - break; - } - }); - - return result; - }) - .filter((dataPoint) => { - // Filter out data points where any selected custom field has a value of 0 or null - return !customFields.some( - (field) => dataPoint[field] === 0 || dataPoint[field] === null, - ); - }); - - setChartData(processedData); - }); - } else { - // If no custom fields, use the original approach - Promise.all( - selectedDataKeys.map((key) => - fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ - key, - data, - })), - ), - ).then((results) => { - const mergedData: { [timestamp: string]: any } = {}; - results.forEach(({ key, data }) => { - data?.forEach((point: any) => { - const cdtTimestamp = toCDT(point.timestamp); - const ts = cdtTimestamp.toISOString(); - if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; - mergedData[ts][key] = point.value; - }); - }); - const mergedArray = Object.values(mergedData).sort( - (a: any, b: any) => - new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), - ); - setChartData(mergedArray); - }); - } - } - }, [selectedDataKeys, startDate, endDate]); - - useEffect(() => { - if (refreshInterval) { - clearInterval(refreshInterval); - } - - // Only set up refresh interval if enabled - if (!ENABLE_REFRESH_INTERVAL) { - return; - } - - const interval = setInterval(() => { - if (selectedDataKeys.length > 0 && startDate && endDate) { - // Check if any custom fields are selected - const customFields = selectedDataKeys.filter( - ( - key, - ): key is - | "net_power" - | "motor_power" - | "total_solar_power" - | "mppt_sum" - | "battery_energy_ah" - | "battery_soc" - | "motor_power_consumption" => - key === "net_power" || - key === "motor_power" || - key === "total_solar_power" || - key === "mppt_sum" || - key === "battery_energy_ah" || - key === "battery_soc" || - key === "motor_power_consumption", - ); - const regularFields = selectedDataKeys.filter( - (key) => - ![ - "net_power", - "motor_power", - "total_solar_power", - "mppt_sum", - "battery_energy_ah", - "battery_soc", - "motor_power_consumption", - ].includes(key), - ); - - if (customFields.length > 0) { - // If custom fields are selected, fetch all telemetry data - fetchTelemetryDataInRange(startDate, endDate).then((allData) => { - if (!allData || !Array.isArray(allData)) return; - - const processedData = (allData as TelemetryData[]) - .map((dataPoint: any) => { - const result: any = { - timestamp: toCDT( - dataPoint.created_at || - dataPoint.gps?.rx_time || - new Date(), - ), - }; - - // Add regular fields - regularFields.forEach((field) => { - const firstUnderscore = field.indexOf("_"); - if (firstUnderscore !== -1) { - const category = field.substring(0, firstUnderscore); - const fieldName = field.substring(firstUnderscore + 1); - const value = getValueFromPath( - dataPoint, - `${category}.${fieldName}`, - ); - if (value !== undefined) { - result[field] = value; - } - } - }); - - // Calculate custom fields - customFields.forEach((field) => { - switch (field) { - case "net_power": - result[field] = calculateNetPower(dataPoint); - break; - case "motor_power": - result[field] = calculateMotorPower(dataPoint); - break; - case "total_solar_power": - result[field] = calculateTotalSolarPower(dataPoint); - break; - case "mppt_sum": - result[field] = - dataPoint.mppt1.output_v + - dataPoint.mppt2.output_v + - dataPoint.mppt3.output_v; - break; - case "net_power": - // Calculate battery power: voltage * current - // Only include if both voltage and current are non-zero - const voltage = dataPoint.battery?.main_bat_v; - const current = dataPoint.battery?.main_bat_c; - if (voltage && current) { - result[field] = voltage * current; - } else { - result[field] = 0; - } - break; - case "battery_energy_ah": - result[field] = calculateBatteryEnergyAh(dataPoint); - break; - case "battery_soc": - result[field] = calculateBatterySOC(dataPoint); - break; - case "motor_power_consumption": - const motorPowerConsumption = - calculateMotorPowerConsumption(dataPoint); - result[field] = motorPowerConsumption; - break; - } - }); - - return result; - }) - .filter((dataPoint) => { - // Filter out data points where any selected custom field has a value of 0 or null - return !customFields.some( - (field) => - dataPoint[field] === 0 || dataPoint[field] === null, - ); - }); - - setChartData(processedData); - }); - } else { - // If no custom fields, use the original approach - Promise.all( - selectedDataKeys.map((key) => - fetchTelemetryDataInRange(startDate, endDate, key).then( - (data) => ({ - key, - data, - }), - ), - ), - ).then((results) => { - const mergedData: { [timestamp: string]: any } = {}; - results.forEach(({ key, data }) => { - data?.forEach((point: any) => { - const cdtTimestamp = toCDT(point.timestamp); - const ts = cdtTimestamp.toISOString(); - if (!mergedData[ts]) - mergedData[ts] = { timestamp: cdtTimestamp }; - mergedData[ts][key] = point.value; - }); - }); - const mergedArray = Object.values(mergedData).sort( - (a: any, b: any) => - new Date(a.timestamp).getTime() - - new Date(b.timestamp).getTime(), - ); - setChartData(mergedArray); - }); - } - } - }, 2000); - setRefreshInterval(interval); - - return () => clearInterval(interval); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDataKeys, startDate, endDate]); - - // Filter chart data based on Y trim values - const filteredChartData = chartData.filter((dataPoint) => { - // Check if any of the selected data keys have values outside the trim range - for (const key of selectedDataKeys) { - const value = dataPoint[key]; - if (value !== undefined && value !== null) { - if (minYTrim !== undefined && value < minYTrim) return false; - if (maxYTrim !== undefined && value > maxYTrim) return false; - } - } - return true; - }); - - // Generate dynamic chart - const chartConfig = generateChartConfig(selectedDataKeys, lineColors); - - // Check for validation errors and return early if any exist - const validationError = getValidationError(); - if (validationError) { - return validationError; - } - - const [integrals, setintegrals] = useState([]); - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - setintegrals(calcIntegrals); - } - }, [chartData]); - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - setintegrals(calcIntegrals); - } - }, [chartData]); - - return ( -
-
-
- - - - - - {selectGroups.map((group) => ( -
- {group.label} - - {group.options.map((option) => ( - { - const value = option.value.replace(".", "_"); - let updatedKeys: string[]; - - if (multiselectEnabled) { - // Multiselect mode: add/remove from array - updatedKeys = checked - ? [...selectedDataKeys, value] - : selectedDataKeys.filter((key) => key !== value); - } else { - // Single select mode: replace selection - updatedKeys = checked ? [value] : []; - } - - handleValueChange(updatedKeys); - }} - > - {option.label} - - ))} -
- ))} -
-
-
- { - setMultiselectEnabled(checked as boolean); - // If disabling multiselect and multiple items are selected, keep only the first one - if (!checked && selectedDataKeys.length > 1) { - setSelectedDataKeys([selectedDataKeys[0]]); - } - }} - /> - -
-
-
-
- - - - - - { - setStartDate(date); - setOpen1(false); - }} - /> - - -
-
- { - if (startDate) { - const newDate = new Date(startDate); - const [hours, minutes] = e.target.value.split(":"); - newDate.setHours(parseInt(hours)); - newDate.setMinutes(parseInt(minutes)); - setStartDate(newDate); - } - }} - className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" - /> -
-
-
-
- - - - - - { - setEndDate(date); - setOpen2(false); - }} - /> - - -
-
- { - if (endDate) { - const newDate = new Date(endDate); - const [hours, minutes] = e.target.value.split(":"); - newDate.setHours(parseInt(hours)); - newDate.setMinutes(parseInt(minutes)); - setEndDate(newDate); - } - }} - className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" - /> -
-
-
- - {/* Y-Axis Trim Controls and Export */} -
-
- - { - const value = e.target.value; - setMinYTrim(value === "" ? undefined : parseFloat(value)); - }} - className="w-32" - /> -
-
- - { - const value = e.target.value; - setMaxYTrim(value === "" ? undefined : parseFloat(value)); - }} - className="w-32" - /> -
-
- - - - - - - - Export Telemetry Data - - This will download a CSV file containing{" "} - {filteredChartData.length} data points for the selected fields - from {startDate?.toLocaleDateString()} to{" "} - {endDate?.toLocaleDateString()}. - {selectedDataKeys.length > 0 && ( - - Selected fields:{" "} - {selectedDataKeys - .map((key) => getLabelFromDataKey(key)) - .join(", ")} - - )} - - - - Cancel - - Download CSV - - - - -
-
- -

- - - - { - const date = value instanceof Date ? value : new Date(value); - return date.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - }} - /> - - Number.parseFloat(value.toFixed(1)).toString() - } - /> - { - // Get the timestamp from the payload data - if (payload && payload.length > 0 && payload[0].payload) { - const timestamp = payload[0].payload.timestamp; - try { - const date = new Date(timestamp); - if (!isNaN(date.getTime())) { - return date.toLocaleString(); - } - } catch (error) { - console.error("Error parsing timestamp:", error); - } - } - return String(value); - }} - /> - } - /> - } /> - {selectedDataKeys.map((key) => { - return ( - - ); - })} - - -
-
- {selectedDataKeys.map((key, index) => { - const value = integrals[index]; - const title = - "Integral Value of " + - getLabelFromDataKey(key) + - " " + - Number(value).toFixed(2); - return ( - - ); - })} -
- {selectedDataKeys.map((key, index) => { - return ( -
- {key} : {integrals[index]} -
- ); - })} -
- ); + const [open1, setOpen1] = useState(false); + const [open2, setOpen2] = useState(false); + const [multiselectEnabled, setMultiselectEnabled] = useState(false); + const [selectedDataKeys, setSelectedDataKeys] = useState(["battery_main_bat_v"]); + const [chartData, setChartData] = useState([]); + const [startDate, setStartDate] = useState(() => { + return new Date("2025-07-04T01:00:00"); + }); + const [refreshInterval, setRefreshInterval] = useState(null); + const [endDate, setEndDate] = useState(() => { + return new Date("2025-07-06T01:00:00"); + }); + + const [lineColors] = useState(["#8884D8", "#D88884", "#84D888", "#884D88", "#88884D", "#4D8888"]); + + const [minYTrim, setMinYTrim] = useState(undefined); + const [maxYTrim, setMaxYTrim] = useState(undefined); + + // Validation function to check for errors + const getValidationError = () => { + // Validate data and dateData are not null + if (chartData === null) { + return ( +
+
+

Data Error

+

Chart data is null or invalid

+
+
+ ); + } + + if (startDate === null || endDate === null) { + return ( +
+
+

Date Error

+

Date data is null or invalid

+
+
+ ); + } + + // Iterate through all data points and check for null values + if (chartData.length > 0) { + const hasNullData = chartData.some((dataPoint) => { + if (dataPoint === null || dataPoint === undefined) { + return true; + } + // Check if any selected data key has null values + return selectedDataKeys.some((key) => { + const value = dataPoint[key]; + return value === null; + }); + }); + + if (hasNullData) { + return ( +
+
+

Data Error

+

Some data points contain null values

+
+
+ ); + } + } + + return null; + }; + + const selectGroups = generateSelectGroups(); + + // CSV Export function + const exportToCSV = () => { + if (filteredChartData.length === 0) { + return; + } + + // Create CSV headers + const headers = ["timestamp", ...selectedDataKeys]; + const csvHeaders = headers + .map((header) => (header === "timestamp" ? "Timestamp" : getLabelFromDataKey(header))) + .join(","); + + // Create CSV rows + const csvRows = filteredChartData.map((dataPoint) => { + return headers + .map((header) => { + if (header === "timestamp") { + const date = new Date(dataPoint.timestamp); + return `"${date.toLocaleString()}"`; + } + const value = dataPoint[header]; + return value !== undefined && value !== null ? value : ""; + }) + .join(","); + }); + + // Combine headers and rows + const csvContent = [csvHeaders, ...csvRows].join("\n"); + + // Create and download file + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + + // Generate filename with date range + const startStr = startDate?.toLocaleDateString().replace(/\//g, "-") || "start"; + const endStr = endDate?.toLocaleDateString().replace(/\//g, "-") || "end"; + link.setAttribute("download", `telemetry-data-${startStr}-to-${endStr}.csv`); + + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleValueChange = (values: string[]) => { + setSelectedDataKeys(values.map((v) => v.replace(".", "_"))); + }; + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + } + setintegrals(calcIntegrals); + }, [selectedDataKeys, chartData]); + + useEffect(() => { + if (selectedDataKeys.length > 0 && startDate && endDate) { + // Check if any custom fields are selected + const customFields = selectedDataKeys.filter( + ( + key + ): key is + | "net_power" + | "motor_power" + | "total_solar_power" + | "mppt_sum" + | "battery_energy_ah" + | "battery_soc" + | "motor_power_consumption" => + key === "net_power" || + key === "motor_power" || + key === "total_solar_power" || + key === "mppt_sum" || + key === "battery_energy_ah" || + key === "battery_soc" || + key === "motor_power_consumption" + ); + const regularFields = selectedDataKeys.filter( + (key) => + ![ + "net_power", + "motor_power", + "total_solar_power", + "mppt_sum", + "battery_energy_ah", + "battery_soc", + "motor_power_consumption", + ].includes(key) + ); + + if (customFields.length > 0) { + // If custom fields are selected, fetch all telemetry data + fetchTelemetryDataInRange(startDate, endDate).then((allData) => { + if (!allData || !Array.isArray(allData)) return; + + const processedData = (allData as TelemetryData[]) + .map((dataPoint: any) => { + const result: any = { + timestamp: toCDT(dataPoint.created_at || dataPoint.gps?.rx_time || new Date()), + }; + + // Add regular fields + regularFields.forEach((field) => { + const firstUnderscore = field.indexOf("_"); + if (firstUnderscore !== -1) { + const category = field.substring(0, firstUnderscore); + const fieldName = field.substring(firstUnderscore + 1); + const value = getValueFromPath(dataPoint, `${category}.${fieldName}`); + if (value !== undefined) { + result[field] = value; + } + } + }); + + // Calculate custom fields + customFields.forEach((field) => { + switch (field) { + case "net_power": + result[field] = calculateNetPower(dataPoint); + break; + case "motor_power": + result[field] = calculateMotorPower(dataPoint); + break; + case "total_solar_power": + result[field] = calculateTotalSolarPower(dataPoint); + break; + case "mppt_sum": + result[field] = + dataPoint.mppt1.output_v + + dataPoint.mppt2.output_v + + dataPoint.mppt3.output_v; + break; + case "net_power": + // Calculate battery power: voltage * current + // Only include if both voltage and current are non-zero + const voltage = dataPoint.battery?.main_bat_v; + const current = dataPoint.battery?.main_bat_c; + if (voltage && current) { + result[field] = voltage * current; + } else { + result[field] = 0; + } + break; + case "battery_energy_ah": + result[field] = calculateBatteryEnergyAh(dataPoint); + break; + case "battery_soc": + result[field] = calculateBatterySOC(dataPoint); + break; + case "motor_power_consumption": + const motorPowerConsumption = calculateMotorPowerConsumption(dataPoint); + result[field] = motorPowerConsumption; + break; + } + }); + + return result; + }) + .filter((dataPoint) => { + // Filter out data points where any selected custom field has a value of 0 or null + return !customFields.some((field) => dataPoint[field] === 0 || dataPoint[field] === null); + }); + + setChartData(processedData); + }); + } else { + // If no custom fields, use the original approach + Promise.all( + selectedDataKeys.map((key) => + fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ + key, + data, + })) + ) + ).then((results) => { + const mergedData: { [timestamp: string]: any } = {}; + results.forEach(({ key, data }) => { + data?.forEach((point: any) => { + const cdtTimestamp = toCDT(point.timestamp); + const ts = cdtTimestamp.toISOString(); + if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; + mergedData[ts][key] = point.value; + }); + }); + const mergedArray = Object.values(mergedData).sort( + (a: any, b: any) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + setChartData(mergedArray); + }); + } + } + }, [selectedDataKeys, startDate, endDate]); + + useEffect(() => { + if (refreshInterval) { + clearInterval(refreshInterval); + } + + // Only set up refresh interval if enabled + if (!ENABLE_REFRESH_INTERVAL) { + return; + } + + const interval = setInterval(() => { + if (selectedDataKeys.length > 0 && startDate && endDate) { + // Check if any custom fields are selected + const customFields = selectedDataKeys.filter( + ( + key + ): key is + | "net_power" + | "motor_power" + | "total_solar_power" + | "mppt_sum" + | "battery_energy_ah" + | "battery_soc" + | "motor_power_consumption" => + key === "net_power" || + key === "motor_power" || + key === "total_solar_power" || + key === "mppt_sum" || + key === "battery_energy_ah" || + key === "battery_soc" || + key === "motor_power_consumption" + ); + const regularFields = selectedDataKeys.filter( + (key) => + ![ + "net_power", + "motor_power", + "total_solar_power", + "mppt_sum", + "battery_energy_ah", + "battery_soc", + "motor_power_consumption", + ].includes(key) + ); + + if (customFields.length > 0) { + // If custom fields are selected, fetch all telemetry data + fetchTelemetryDataInRange(startDate, endDate).then((allData) => { + if (!allData || !Array.isArray(allData)) return; + + const processedData = (allData as TelemetryData[]) + .map((dataPoint: any) => { + const result: any = { + timestamp: toCDT(dataPoint.created_at || dataPoint.gps?.rx_time || new Date()), + }; + + // Add regular fields + regularFields.forEach((field) => { + const firstUnderscore = field.indexOf("_"); + if (firstUnderscore !== -1) { + const category = field.substring(0, firstUnderscore); + const fieldName = field.substring(firstUnderscore + 1); + const value = getValueFromPath(dataPoint, `${category}.${fieldName}`); + if (value !== undefined) { + result[field] = value; + } + } + }); + + // Calculate custom fields + customFields.forEach((field) => { + switch (field) { + case "net_power": + result[field] = calculateNetPower(dataPoint); + break; + case "motor_power": + result[field] = calculateMotorPower(dataPoint); + break; + case "total_solar_power": + result[field] = calculateTotalSolarPower(dataPoint); + break; + case "mppt_sum": + result[field] = + dataPoint.mppt1.output_v + + dataPoint.mppt2.output_v + + dataPoint.mppt3.output_v; + break; + case "net_power": + // Calculate battery power: voltage * current + // Only include if both voltage and current are non-zero + const voltage = dataPoint.battery?.main_bat_v; + const current = dataPoint.battery?.main_bat_c; + if (voltage && current) { + result[field] = voltage * current; + } else { + result[field] = 0; + } + break; + case "battery_energy_ah": + result[field] = calculateBatteryEnergyAh(dataPoint); + break; + case "battery_soc": + result[field] = calculateBatterySOC(dataPoint); + break; + case "motor_power_consumption": + const motorPowerConsumption = calculateMotorPowerConsumption(dataPoint); + result[field] = motorPowerConsumption; + break; + } + }); + + return result; + }) + .filter((dataPoint) => { + // Filter out data points where any selected custom field has a value of 0 or null + return !customFields.some( + (field) => dataPoint[field] === 0 || dataPoint[field] === null + ); + }); + + setChartData(processedData); + }); + } else { + // If no custom fields, use the original approach + Promise.all( + selectedDataKeys.map((key) => + fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ + key, + data, + })) + ) + ).then((results) => { + const mergedData: { [timestamp: string]: any } = {}; + results.forEach(({ key, data }) => { + data?.forEach((point: any) => { + const cdtTimestamp = toCDT(point.timestamp); + const ts = cdtTimestamp.toISOString(); + if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; + mergedData[ts][key] = point.value; + }); + }); + const mergedArray = Object.values(mergedData).sort( + (a: any, b: any) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + ); + setChartData(mergedArray); + }); + } + } + }, 2000); + setRefreshInterval(interval); + + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDataKeys, startDate, endDate]); + + // Filter chart data based on Y trim values + const filteredChartData = chartData.filter((dataPoint) => { + // Check if any of the selected data keys have values outside the trim range + for (const key of selectedDataKeys) { + const value = dataPoint[key]; + if (value !== undefined && value !== null) { + if (minYTrim !== undefined && value < minYTrim) return false; + if (maxYTrim !== undefined && value > maxYTrim) return false; + } + } + return true; + }); + + // Generate dynamic chart + const chartConfig = generateChartConfig(selectedDataKeys, lineColors); + + // Check for validation errors and return early if any exist + const validationError = getValidationError(); + if (validationError) { + return validationError; + } + + const [integrals, setintegrals] = useState([]); + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + setintegrals(calcIntegrals); + } + }, [chartData]); + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + setintegrals(calcIntegrals); + } + }, [chartData]); + + return ( +
+
+
+ + + + + + {selectGroups.map((group) => ( +
+ {group.label} + + {group.options.map((option) => ( + { + const value = option.value.replace(".", "_"); + let updatedKeys: string[]; + + if (multiselectEnabled) { + // Multiselect mode: add/remove from array + updatedKeys = checked + ? [...selectedDataKeys, value] + : selectedDataKeys.filter((key) => key !== value); + } else { + // Single select mode: replace selection + updatedKeys = checked ? [value] : []; + } + + handleValueChange(updatedKeys); + }} + > + {option.label} + + ))} +
+ ))} +
+
+
+ { + setMultiselectEnabled(checked as boolean); + // If disabling multiselect and multiple items are selected, keep only the first one + if (!checked && selectedDataKeys.length > 1) { + setSelectedDataKeys([selectedDataKeys[0]]); + } + }} + /> + +
+
+
+
+ + + + + + { + setStartDate(date); + setOpen1(false); + }} + /> + + +
+
+ { + if (startDate) { + const newDate = new Date(startDate); + const [hours, minutes] = e.target.value.split(":"); + newDate.setHours(parseInt(hours)); + newDate.setMinutes(parseInt(minutes)); + setStartDate(newDate); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + /> +
+
+
+
+ + + + + + { + setEndDate(date); + setOpen2(false); + }} + /> + + +
+
+ { + if (endDate) { + const newDate = new Date(endDate); + const [hours, minutes] = e.target.value.split(":"); + newDate.setHours(parseInt(hours)); + newDate.setMinutes(parseInt(minutes)); + setEndDate(newDate); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + /> +
+
+
+ + {/* Y-Axis Trim Controls and Export */} +
+
+ + { + const value = e.target.value; + setMinYTrim(value === "" ? undefined : parseFloat(value)); + }} + className="w-32" + /> +
+
+ + { + const value = e.target.value; + setMaxYTrim(value === "" ? undefined : parseFloat(value)); + }} + className="w-32" + /> +
+
+ + + + + + + + Export Telemetry Data + + This will download a CSV file containing {filteredChartData.length} data points for + the selected fields from {startDate?.toLocaleDateString()} to{" "} + {endDate?.toLocaleDateString()}. + {selectedDataKeys.length > 0 && ( + + Selected fields:{" "} + {selectedDataKeys.map((key) => getLabelFromDataKey(key)).join(", ")} + + )} + + + + Cancel + Download CSV + + + +
+
+ +

+ + + + { + const date = value instanceof Date ? value : new Date(value); + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + }} + /> + Number.parseFloat(value.toFixed(1)).toString()} + /> + { + // Get the timestamp from the payload data + if (payload && payload.length > 0 && payload[0].payload) { + const timestamp = payload[0].payload.timestamp; + try { + const date = new Date(timestamp); + if (!isNaN(date.getTime())) { + return date.toLocaleString(); + } + } catch (error) { + console.error("Error parsing timestamp:", error); + } + } + return String(value); + }} + /> + } + /> + } /> + {selectedDataKeys.map((key) => { + return ( + + ); + })} + + +
+
+ {selectedDataKeys.map((key, index) => { + const value = integrals[index]; + const title = "Integral Value of " + getLabelFromDataKey(key) + " " + Number(value).toFixed(2); + return ; + })} +
+ {selectedDataKeys.map((key, index) => { + return ( +
+ {key} : {integrals[index]} +
+ ); + })} +
+ ); } From 9ce66c8a577ac2a93f9665aba133a5ad1318e055 Mon Sep 17 00:00:00 2001 From: Anthony Zheng Date: Thu, 15 Jan 2026 22:00:09 -0500 Subject: [PATCH 6/6] Revert "removed unused imports" This reverts commit 15659c43ce1b23f99da80a5955faa3a854a27141. --- components/pages/StatsGraphTab.tsx | 1849 +++++++++++++++------------- 1 file changed, 974 insertions(+), 875 deletions(-) diff --git a/components/pages/StatsGraphTab.tsx b/components/pages/StatsGraphTab.tsx index a852b63..ac1ddd8 100644 --- a/components/pages/StatsGraphTab.tsx +++ b/components/pages/StatsGraphTab.tsx @@ -1,31 +1,36 @@ import { TelemetryData } from "@/lib/types"; import { CartesianGrid, Line, LineChart, XAxis, YAxis, Legend } from "recharts"; +import { DropdownMenuCheckboxItemProps } from "@radix-ui/react-dropdown-menu"; import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, - ChartLegend, - ChartLegendContent, + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, } from "@/components/ui/chart"; import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { generateSelectGroups, getValueFromPath, TELEMETRY_FIELD_CONFIG } from "@/lib/chart-config"; import { - getCustomValue, - calculateNetPower, - calculateMotorPower, - calculateTotalSolarPower, - calculateBatteryEnergyAh, - calculateBatterySOC, - calculateMotorPowerConsumption, - mapTelemetryData, + generateSelectGroups, + getValueFromPath, + TELEMETRY_FIELD_CONFIG, +} from "@/lib/chart-config"; +import { + getCustomValue, + calculateNetPower, + calculateMotorPower, + calculateTotalSolarPower, + calculateBatteryEnergyAh, + calculateBatterySOC, + calculateMotorPowerConsumption, + mapTelemetryData, } from "@/lib/telemetry-utils"; import { useEffect, useState } from "react"; import { Axis3D, ChevronDownIcon, Download } from "lucide-react"; @@ -34,40 +39,46 @@ import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { fetchTelemetryDataInRange } from "@/lib/db-utils"; import SimpleCard from "../telemetry/SimpleCard"; +import { Telemetry } from "next/dist/telemetry/storage"; function integrateData(data: any, dataKey: string) { - let integral = 0; + let integral = 0; - for (let i = 0; i < data.length - 1; i++) { - const val1 = data[i][dataKey]; - const val2 = data[i + 1][dataKey]; + for (let i = 0; i < data.length - 1; i++) { + const val1 = data[i][dataKey]; + const val2 = data[i + 1][dataKey]; - if (val1 == null || val2 == null) { - continue; - } + if (val1 == null || val2 == null) { + continue; + } - let date1 = new Date(data[i + 1].timestamp); - let date2 = new Date(data[i].timestamp); - let changeX = date1.getTime() - date2.getTime(); + let date1 = new Date(data[i + 1].timestamp); + let date2 = new Date(data[i].timestamp); + let changeX = date1.getTime() - date2.getTime(); - let midVal = (val2 + val1) / 2; - integral += (midVal * changeX) / 1000; - } - return integral; + let midVal = (val2 + val1) / 2; + integral += (midVal * changeX) / 1000; + } + console.log("PENIS"); + return integral; } // Configuration constant to enable/disable refresh interval @@ -75,848 +86,936 @@ const ENABLE_REFRESH_INTERVAL = false; // Helper function to convert any date to CDT (UTC-5) function toCDT(date: Date | string): Date { - const d = new Date(date); + const d = new Date(date); - // Always subtract 1 hour regardless of environment - let adjustedTime = new Date(d.getTime() - 1 * 60 * 60 * 1000); // subtract 1 hour + // Always subtract 1 hour regardless of environment + let adjustedTime = new Date(d.getTime() - 1 * 60 * 60 * 1000); // subtract 1 hour - // Check if we're in local development (not production) - const isLocal = - process.env.NODE_ENV === "development" || - (typeof window !== "undefined" && window.location.hostname === "localhost"); + // Check if we're in local development (not production) + const isLocal = + process.env.NODE_ENV === "development" || + (typeof window !== "undefined" && window.location.hostname === "localhost"); - // If local, subtract additional 4 hours to match production timezone - if (isLocal) { - adjustedTime = new Date(adjustedTime.getTime() - 4 * 60 * 60 * 1000); // subtract additional 4 hours - } + // If local, subtract additional 4 hours to match production timezone + if (isLocal) { + adjustedTime = new Date(adjustedTime.getTime() - 4 * 60 * 60 * 1000); // subtract additional 4 hours + } - return adjustedTime; + return adjustedTime; } // Helper function to get label from data key function getLabelFromDataKey(dataKey: string): string { - // Handle custom fields - switch (dataKey) { - case "net_power": - return "Net Power"; - case "motor_power": - return "Motor Power"; - case "total_solar_power": - return "Total Solar Power"; - case "mppt_sum": - return "Total MPPT Voltage Output"; - case "battery_energy_ah": - return "Battery Remaining Energy (Ah)"; - case "battery_soc": - return "Battery SOC (%)"; - case "motor_power_consumption": - return "Motor Power Consumption"; - } - - // Convert from database format (e.g., "battery_main_bat_v") to config format (e.g., "battery.main_bat_v") - // Find the first underscore to separate category from the rest - const firstUnderscoreIndex = dataKey.indexOf("_"); - if (firstUnderscoreIndex === -1) { - return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); - } - - const category = dataKey.substring(0, firstUnderscoreIndex); - const field = dataKey.substring(firstUnderscoreIndex + 1); - - if (category && field && TELEMETRY_FIELD_CONFIG[category]?.fields[field]) { - return TELEMETRY_FIELD_CONFIG[category].fields[field]; - } - - // Fallback to formatted version of the key - return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); + // Handle custom fields + switch (dataKey) { + case "net_power": + return "Net Power"; + case "motor_power": + return "Motor Power"; + case "total_solar_power": + return "Total Solar Power"; + case "mppt_sum": + return "Total MPPT Voltage Output"; + case "battery_energy_ah": + return "Battery Remaining Energy (Ah)"; + case "battery_soc": + return "Battery SOC (%)"; + case "motor_power_consumption": + return "Motor Power Consumption"; + } + + // Convert from database format (e.g., "battery_main_bat_v") to config format (e.g., "battery.main_bat_v") + // Find the first underscore to separate category from the rest + const firstUnderscoreIndex = dataKey.indexOf("_"); + if (firstUnderscoreIndex === -1) { + return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); + } + + const category = dataKey.substring(0, firstUnderscoreIndex); + const field = dataKey.substring(firstUnderscoreIndex + 1); + + if (category && field && TELEMETRY_FIELD_CONFIG[category]?.fields[field]) { + return TELEMETRY_FIELD_CONFIG[category].fields[field]; + } + + // Fallback to formatted version of the key + return dataKey.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); } // Generate dynamic chart config based on selected data keys -function generateChartConfig(selectedDataKeys: string[], lineColors: string[]): ChartConfig { - const config: ChartConfig = {}; - - selectedDataKeys.forEach((key, index) => { - config[key] = { - label: getLabelFromDataKey(key), - color: lineColors[index % lineColors.length], - }; - }); - - return config; +function generateChartConfig( + selectedDataKeys: string[], + lineColors: string[], +): ChartConfig { + const config: ChartConfig = {}; + + selectedDataKeys.forEach((key, index) => { + config[key] = { + label: getLabelFromDataKey(key), + color: lineColors[index % lineColors.length], + }; + }); + + return config; } export default function StatsGraphTab() { - const [open1, setOpen1] = useState(false); - const [open2, setOpen2] = useState(false); - const [multiselectEnabled, setMultiselectEnabled] = useState(false); - const [selectedDataKeys, setSelectedDataKeys] = useState(["battery_main_bat_v"]); - const [chartData, setChartData] = useState([]); - const [startDate, setStartDate] = useState(() => { - return new Date("2025-07-04T01:00:00"); - }); - const [refreshInterval, setRefreshInterval] = useState(null); - const [endDate, setEndDate] = useState(() => { - return new Date("2025-07-06T01:00:00"); - }); - - const [lineColors] = useState(["#8884D8", "#D88884", "#84D888", "#884D88", "#88884D", "#4D8888"]); - - const [minYTrim, setMinYTrim] = useState(undefined); - const [maxYTrim, setMaxYTrim] = useState(undefined); - - // Validation function to check for errors - const getValidationError = () => { - // Validate data and dateData are not null - if (chartData === null) { - return ( -
-
-

Data Error

-

Chart data is null or invalid

-
-
- ); - } - - if (startDate === null || endDate === null) { - return ( -
-
-

Date Error

-

Date data is null or invalid

-
-
- ); - } - - // Iterate through all data points and check for null values - if (chartData.length > 0) { - const hasNullData = chartData.some((dataPoint) => { - if (dataPoint === null || dataPoint === undefined) { - return true; - } - // Check if any selected data key has null values - return selectedDataKeys.some((key) => { - const value = dataPoint[key]; - return value === null; - }); - }); - - if (hasNullData) { - return ( -
-
-

Data Error

-

Some data points contain null values

-
-
- ); - } - } - - return null; - }; - - const selectGroups = generateSelectGroups(); - - // CSV Export function - const exportToCSV = () => { - if (filteredChartData.length === 0) { - return; - } - - // Create CSV headers - const headers = ["timestamp", ...selectedDataKeys]; - const csvHeaders = headers - .map((header) => (header === "timestamp" ? "Timestamp" : getLabelFromDataKey(header))) - .join(","); - - // Create CSV rows - const csvRows = filteredChartData.map((dataPoint) => { - return headers - .map((header) => { - if (header === "timestamp") { - const date = new Date(dataPoint.timestamp); - return `"${date.toLocaleString()}"`; - } - const value = dataPoint[header]; - return value !== undefined && value !== null ? value : ""; - }) - .join(","); - }); - - // Combine headers and rows - const csvContent = [csvHeaders, ...csvRows].join("\n"); - - // Create and download file - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const link = document.createElement("a"); - const url = URL.createObjectURL(blob); - link.setAttribute("href", url); - - // Generate filename with date range - const startStr = startDate?.toLocaleDateString().replace(/\//g, "-") || "start"; - const endStr = endDate?.toLocaleDateString().replace(/\//g, "-") || "end"; - link.setAttribute("download", `telemetry-data-${startStr}-to-${endStr}.csv`); - - link.style.visibility = "hidden"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - - const handleValueChange = (values: string[]) => { - setSelectedDataKeys(values.map((v) => v.replace(".", "_"))); - }; - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - } - setintegrals(calcIntegrals); - }, [selectedDataKeys, chartData]); - - useEffect(() => { - if (selectedDataKeys.length > 0 && startDate && endDate) { - // Check if any custom fields are selected - const customFields = selectedDataKeys.filter( - ( - key - ): key is - | "net_power" - | "motor_power" - | "total_solar_power" - | "mppt_sum" - | "battery_energy_ah" - | "battery_soc" - | "motor_power_consumption" => - key === "net_power" || - key === "motor_power" || - key === "total_solar_power" || - key === "mppt_sum" || - key === "battery_energy_ah" || - key === "battery_soc" || - key === "motor_power_consumption" - ); - const regularFields = selectedDataKeys.filter( - (key) => - ![ - "net_power", - "motor_power", - "total_solar_power", - "mppt_sum", - "battery_energy_ah", - "battery_soc", - "motor_power_consumption", - ].includes(key) - ); - - if (customFields.length > 0) { - // If custom fields are selected, fetch all telemetry data - fetchTelemetryDataInRange(startDate, endDate).then((allData) => { - if (!allData || !Array.isArray(allData)) return; - - const processedData = (allData as TelemetryData[]) - .map((dataPoint: any) => { - const result: any = { - timestamp: toCDT(dataPoint.created_at || dataPoint.gps?.rx_time || new Date()), - }; - - // Add regular fields - regularFields.forEach((field) => { - const firstUnderscore = field.indexOf("_"); - if (firstUnderscore !== -1) { - const category = field.substring(0, firstUnderscore); - const fieldName = field.substring(firstUnderscore + 1); - const value = getValueFromPath(dataPoint, `${category}.${fieldName}`); - if (value !== undefined) { - result[field] = value; - } - } - }); - - // Calculate custom fields - customFields.forEach((field) => { - switch (field) { - case "net_power": - result[field] = calculateNetPower(dataPoint); - break; - case "motor_power": - result[field] = calculateMotorPower(dataPoint); - break; - case "total_solar_power": - result[field] = calculateTotalSolarPower(dataPoint); - break; - case "mppt_sum": - result[field] = - dataPoint.mppt1.output_v + - dataPoint.mppt2.output_v + - dataPoint.mppt3.output_v; - break; - case "net_power": - // Calculate battery power: voltage * current - // Only include if both voltage and current are non-zero - const voltage = dataPoint.battery?.main_bat_v; - const current = dataPoint.battery?.main_bat_c; - if (voltage && current) { - result[field] = voltage * current; - } else { - result[field] = 0; - } - break; - case "battery_energy_ah": - result[field] = calculateBatteryEnergyAh(dataPoint); - break; - case "battery_soc": - result[field] = calculateBatterySOC(dataPoint); - break; - case "motor_power_consumption": - const motorPowerConsumption = calculateMotorPowerConsumption(dataPoint); - result[field] = motorPowerConsumption; - break; - } - }); - - return result; - }) - .filter((dataPoint) => { - // Filter out data points where any selected custom field has a value of 0 or null - return !customFields.some((field) => dataPoint[field] === 0 || dataPoint[field] === null); - }); - - setChartData(processedData); - }); - } else { - // If no custom fields, use the original approach - Promise.all( - selectedDataKeys.map((key) => - fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ - key, - data, - })) - ) - ).then((results) => { - const mergedData: { [timestamp: string]: any } = {}; - results.forEach(({ key, data }) => { - data?.forEach((point: any) => { - const cdtTimestamp = toCDT(point.timestamp); - const ts = cdtTimestamp.toISOString(); - if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; - mergedData[ts][key] = point.value; - }); - }); - const mergedArray = Object.values(mergedData).sort( - (a: any, b: any) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); - setChartData(mergedArray); - }); - } - } - }, [selectedDataKeys, startDate, endDate]); - - useEffect(() => { - if (refreshInterval) { - clearInterval(refreshInterval); - } - - // Only set up refresh interval if enabled - if (!ENABLE_REFRESH_INTERVAL) { - return; - } - - const interval = setInterval(() => { - if (selectedDataKeys.length > 0 && startDate && endDate) { - // Check if any custom fields are selected - const customFields = selectedDataKeys.filter( - ( - key - ): key is - | "net_power" - | "motor_power" - | "total_solar_power" - | "mppt_sum" - | "battery_energy_ah" - | "battery_soc" - | "motor_power_consumption" => - key === "net_power" || - key === "motor_power" || - key === "total_solar_power" || - key === "mppt_sum" || - key === "battery_energy_ah" || - key === "battery_soc" || - key === "motor_power_consumption" - ); - const regularFields = selectedDataKeys.filter( - (key) => - ![ - "net_power", - "motor_power", - "total_solar_power", - "mppt_sum", - "battery_energy_ah", - "battery_soc", - "motor_power_consumption", - ].includes(key) - ); - - if (customFields.length > 0) { - // If custom fields are selected, fetch all telemetry data - fetchTelemetryDataInRange(startDate, endDate).then((allData) => { - if (!allData || !Array.isArray(allData)) return; - - const processedData = (allData as TelemetryData[]) - .map((dataPoint: any) => { - const result: any = { - timestamp: toCDT(dataPoint.created_at || dataPoint.gps?.rx_time || new Date()), - }; - - // Add regular fields - regularFields.forEach((field) => { - const firstUnderscore = field.indexOf("_"); - if (firstUnderscore !== -1) { - const category = field.substring(0, firstUnderscore); - const fieldName = field.substring(firstUnderscore + 1); - const value = getValueFromPath(dataPoint, `${category}.${fieldName}`); - if (value !== undefined) { - result[field] = value; - } - } - }); - - // Calculate custom fields - customFields.forEach((field) => { - switch (field) { - case "net_power": - result[field] = calculateNetPower(dataPoint); - break; - case "motor_power": - result[field] = calculateMotorPower(dataPoint); - break; - case "total_solar_power": - result[field] = calculateTotalSolarPower(dataPoint); - break; - case "mppt_sum": - result[field] = - dataPoint.mppt1.output_v + - dataPoint.mppt2.output_v + - dataPoint.mppt3.output_v; - break; - case "net_power": - // Calculate battery power: voltage * current - // Only include if both voltage and current are non-zero - const voltage = dataPoint.battery?.main_bat_v; - const current = dataPoint.battery?.main_bat_c; - if (voltage && current) { - result[field] = voltage * current; - } else { - result[field] = 0; - } - break; - case "battery_energy_ah": - result[field] = calculateBatteryEnergyAh(dataPoint); - break; - case "battery_soc": - result[field] = calculateBatterySOC(dataPoint); - break; - case "motor_power_consumption": - const motorPowerConsumption = calculateMotorPowerConsumption(dataPoint); - result[field] = motorPowerConsumption; - break; - } - }); - - return result; - }) - .filter((dataPoint) => { - // Filter out data points where any selected custom field has a value of 0 or null - return !customFields.some( - (field) => dataPoint[field] === 0 || dataPoint[field] === null - ); - }); - - setChartData(processedData); - }); - } else { - // If no custom fields, use the original approach - Promise.all( - selectedDataKeys.map((key) => - fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ - key, - data, - })) - ) - ).then((results) => { - const mergedData: { [timestamp: string]: any } = {}; - results.forEach(({ key, data }) => { - data?.forEach((point: any) => { - const cdtTimestamp = toCDT(point.timestamp); - const ts = cdtTimestamp.toISOString(); - if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; - mergedData[ts][key] = point.value; - }); - }); - const mergedArray = Object.values(mergedData).sort( - (a: any, b: any) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() - ); - setChartData(mergedArray); - }); - } - } - }, 2000); - setRefreshInterval(interval); - - return () => clearInterval(interval); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDataKeys, startDate, endDate]); - - // Filter chart data based on Y trim values - const filteredChartData = chartData.filter((dataPoint) => { - // Check if any of the selected data keys have values outside the trim range - for (const key of selectedDataKeys) { - const value = dataPoint[key]; - if (value !== undefined && value !== null) { - if (minYTrim !== undefined && value < minYTrim) return false; - if (maxYTrim !== undefined && value > maxYTrim) return false; - } - } - return true; - }); - - // Generate dynamic chart - const chartConfig = generateChartConfig(selectedDataKeys, lineColors); - - // Check for validation errors and return early if any exist - const validationError = getValidationError(); - if (validationError) { - return validationError; - } - - const [integrals, setintegrals] = useState([]); - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - setintegrals(calcIntegrals); - } - }, [chartData]); - - useEffect(() => { - let calcIntegrals = []; - for (let i = 0; i < selectedDataKeys.length; i++) { - calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); - setintegrals(calcIntegrals); - } - }, [chartData]); - - return ( -
-
-
- - - - - - {selectGroups.map((group) => ( -
- {group.label} - - {group.options.map((option) => ( - { - const value = option.value.replace(".", "_"); - let updatedKeys: string[]; - - if (multiselectEnabled) { - // Multiselect mode: add/remove from array - updatedKeys = checked - ? [...selectedDataKeys, value] - : selectedDataKeys.filter((key) => key !== value); - } else { - // Single select mode: replace selection - updatedKeys = checked ? [value] : []; - } - - handleValueChange(updatedKeys); - }} - > - {option.label} - - ))} -
- ))} -
-
-
- { - setMultiselectEnabled(checked as boolean); - // If disabling multiselect and multiple items are selected, keep only the first one - if (!checked && selectedDataKeys.length > 1) { - setSelectedDataKeys([selectedDataKeys[0]]); - } - }} - /> - -
-
-
-
- - - - - - { - setStartDate(date); - setOpen1(false); - }} - /> - - -
-
- { - if (startDate) { - const newDate = new Date(startDate); - const [hours, minutes] = e.target.value.split(":"); - newDate.setHours(parseInt(hours)); - newDate.setMinutes(parseInt(minutes)); - setStartDate(newDate); - } - }} - className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" - /> -
-
-
-
- - - - - - { - setEndDate(date); - setOpen2(false); - }} - /> - - -
-
- { - if (endDate) { - const newDate = new Date(endDate); - const [hours, minutes] = e.target.value.split(":"); - newDate.setHours(parseInt(hours)); - newDate.setMinutes(parseInt(minutes)); - setEndDate(newDate); - } - }} - className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" - /> -
-
-
- - {/* Y-Axis Trim Controls and Export */} -
-
- - { - const value = e.target.value; - setMinYTrim(value === "" ? undefined : parseFloat(value)); - }} - className="w-32" - /> -
-
- - { - const value = e.target.value; - setMaxYTrim(value === "" ? undefined : parseFloat(value)); - }} - className="w-32" - /> -
-
- - - - - - - - Export Telemetry Data - - This will download a CSV file containing {filteredChartData.length} data points for - the selected fields from {startDate?.toLocaleDateString()} to{" "} - {endDate?.toLocaleDateString()}. - {selectedDataKeys.length > 0 && ( - - Selected fields:{" "} - {selectedDataKeys.map((key) => getLabelFromDataKey(key)).join(", ")} - - )} - - - - Cancel - Download CSV - - - -
-
- -

- - - - { - const date = value instanceof Date ? value : new Date(value); - return date.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - }); - }} - /> - Number.parseFloat(value.toFixed(1)).toString()} - /> - { - // Get the timestamp from the payload data - if (payload && payload.length > 0 && payload[0].payload) { - const timestamp = payload[0].payload.timestamp; - try { - const date = new Date(timestamp); - if (!isNaN(date.getTime())) { - return date.toLocaleString(); - } - } catch (error) { - console.error("Error parsing timestamp:", error); - } - } - return String(value); - }} - /> - } - /> - } /> - {selectedDataKeys.map((key) => { - return ( - - ); - })} - - -
-
- {selectedDataKeys.map((key, index) => { - const value = integrals[index]; - const title = "Integral Value of " + getLabelFromDataKey(key) + " " + Number(value).toFixed(2); - return ; - })} -
- {selectedDataKeys.map((key, index) => { - return ( -
- {key} : {integrals[index]} -
- ); - })} -
- ); + const [open1, setOpen1] = useState(false); + const [open2, setOpen2] = useState(false); + const [multiselectEnabled, setMultiselectEnabled] = useState(false); + const [selectedDataKeys, setSelectedDataKeys] = useState([ + "battery_main_bat_v", + ]); + const [chartData, setChartData] = useState([]); + const [startDate, setStartDate] = useState(() => { + return new Date("2025-07-04T01:00:00"); + }); + const [refreshInterval, setRefreshInterval] = useState( + null, + ); + const [endDate, setEndDate] = useState(() => { + return new Date("2025-07-06T01:00:00"); + }); + + const [lineColors] = useState([ + "#8884D8", + "#D88884", + "#84D888", + "#884D88", + "#88884D", + "#4D8888", + ]); + + const [minYTrim, setMinYTrim] = useState(undefined); + const [maxYTrim, setMaxYTrim] = useState(undefined); + + // Validation function to check for errors + const getValidationError = () => { + // Validate data and dateData are not null + if (chartData === null) { + return ( +
+
+

Data Error

+

+ Chart data is null or invalid +

+
+
+ ); + } + + if (startDate === null || endDate === null) { + return ( +
+
+

Date Error

+

+ Date data is null or invalid +

+
+
+ ); + } + + // Iterate through all data points and check for null values + if (chartData.length > 0) { + const hasNullData = chartData.some((dataPoint) => { + if (dataPoint === null || dataPoint === undefined) { + return true; + } + // Check if any selected data key has null values + return selectedDataKeys.some((key) => { + const value = dataPoint[key]; + return value === null; + }); + }); + + if (hasNullData) { + return ( +
+
+

Data Error

+

+ Some data points contain null values +

+
+
+ ); + } + } + + return null; + }; + + const selectGroups = generateSelectGroups(); + + // CSV Export function + const exportToCSV = () => { + if (filteredChartData.length === 0) { + return; + } + + // Create CSV headers + const headers = ["timestamp", ...selectedDataKeys]; + const csvHeaders = headers + .map((header) => + header === "timestamp" ? "Timestamp" : getLabelFromDataKey(header), + ) + .join(","); + + // Create CSV rows + const csvRows = filteredChartData.map((dataPoint) => { + return headers + .map((header) => { + if (header === "timestamp") { + const date = new Date(dataPoint.timestamp); + return `"${date.toLocaleString()}"`; + } + const value = dataPoint[header]; + return value !== undefined && value !== null ? value : ""; + }) + .join(","); + }); + + // Combine headers and rows + const csvContent = [csvHeaders, ...csvRows].join("\n"); + + // Create and download file + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + + // Generate filename with date range + const startStr = + startDate?.toLocaleDateString().replace(/\//g, "-") || "start"; + const endStr = endDate?.toLocaleDateString().replace(/\//g, "-") || "end"; + link.setAttribute( + "download", + `telemetry-data-${startStr}-to-${endStr}.csv`, + ); + + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleValueChange = (values: string[]) => { + setSelectedDataKeys(values.map((v) => v.replace(".", "_"))); + }; + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + } + setintegrals(calcIntegrals); + }, [selectedDataKeys, chartData]); + + useEffect(() => { + if (selectedDataKeys.length > 0 && startDate && endDate) { + // Check if any custom fields are selected + const customFields = selectedDataKeys.filter( + ( + key, + ): key is + | "net_power" + | "motor_power" + | "total_solar_power" + | "mppt_sum" + | "battery_energy_ah" + | "battery_soc" + | "motor_power_consumption" => + key === "net_power" || + key === "motor_power" || + key === "total_solar_power" || + key === "mppt_sum" || + key === "battery_energy_ah" || + key === "battery_soc" || + key === "motor_power_consumption", + ); + const regularFields = selectedDataKeys.filter( + (key) => + ![ + "net_power", + "motor_power", + "total_solar_power", + "mppt_sum", + "battery_energy_ah", + "battery_soc", + "motor_power_consumption", + ].includes(key), + ); + + if (customFields.length > 0) { + // If custom fields are selected, fetch all telemetry data + fetchTelemetryDataInRange(startDate, endDate).then((allData) => { + if (!allData || !Array.isArray(allData)) return; + + const processedData = (allData as TelemetryData[]) + .map((dataPoint: any) => { + const result: any = { + timestamp: toCDT( + dataPoint.created_at || dataPoint.gps?.rx_time || new Date(), + ), + }; + + // Add regular fields + regularFields.forEach((field) => { + const firstUnderscore = field.indexOf("_"); + if (firstUnderscore !== -1) { + const category = field.substring(0, firstUnderscore); + const fieldName = field.substring(firstUnderscore + 1); + const value = getValueFromPath( + dataPoint, + `${category}.${fieldName}`, + ); + if (value !== undefined) { + result[field] = value; + } + } + }); + + // Calculate custom fields + customFields.forEach((field) => { + switch (field) { + case "net_power": + result[field] = calculateNetPower(dataPoint); + break; + case "motor_power": + result[field] = calculateMotorPower(dataPoint); + break; + case "total_solar_power": + result[field] = calculateTotalSolarPower(dataPoint); + break; + case "mppt_sum": + result[field] = + dataPoint.mppt1.output_v + + dataPoint.mppt2.output_v + + dataPoint.mppt3.output_v; + break; + case "net_power": + // Calculate battery power: voltage * current + // Only include if both voltage and current are non-zero + const voltage = dataPoint.battery?.main_bat_v; + const current = dataPoint.battery?.main_bat_c; + if (voltage && current) { + result[field] = voltage * current; + } else { + result[field] = 0; + } + break; + case "battery_energy_ah": + result[field] = calculateBatteryEnergyAh(dataPoint); + break; + case "battery_soc": + result[field] = calculateBatterySOC(dataPoint); + break; + case "motor_power_consumption": + const motorPowerConsumption = + calculateMotorPowerConsumption(dataPoint); + result[field] = motorPowerConsumption; + break; + } + }); + + return result; + }) + .filter((dataPoint) => { + // Filter out data points where any selected custom field has a value of 0 or null + return !customFields.some( + (field) => dataPoint[field] === 0 || dataPoint[field] === null, + ); + }); + + setChartData(processedData); + }); + } else { + // If no custom fields, use the original approach + Promise.all( + selectedDataKeys.map((key) => + fetchTelemetryDataInRange(startDate, endDate, key).then((data) => ({ + key, + data, + })), + ), + ).then((results) => { + const mergedData: { [timestamp: string]: any } = {}; + results.forEach(({ key, data }) => { + data?.forEach((point: any) => { + const cdtTimestamp = toCDT(point.timestamp); + const ts = cdtTimestamp.toISOString(); + if (!mergedData[ts]) mergedData[ts] = { timestamp: cdtTimestamp }; + mergedData[ts][key] = point.value; + }); + }); + const mergedArray = Object.values(mergedData).sort( + (a: any, b: any) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + setChartData(mergedArray); + }); + } + } + }, [selectedDataKeys, startDate, endDate]); + + useEffect(() => { + if (refreshInterval) { + clearInterval(refreshInterval); + } + + // Only set up refresh interval if enabled + if (!ENABLE_REFRESH_INTERVAL) { + return; + } + + const interval = setInterval(() => { + if (selectedDataKeys.length > 0 && startDate && endDate) { + // Check if any custom fields are selected + const customFields = selectedDataKeys.filter( + ( + key, + ): key is + | "net_power" + | "motor_power" + | "total_solar_power" + | "mppt_sum" + | "battery_energy_ah" + | "battery_soc" + | "motor_power_consumption" => + key === "net_power" || + key === "motor_power" || + key === "total_solar_power" || + key === "mppt_sum" || + key === "battery_energy_ah" || + key === "battery_soc" || + key === "motor_power_consumption", + ); + const regularFields = selectedDataKeys.filter( + (key) => + ![ + "net_power", + "motor_power", + "total_solar_power", + "mppt_sum", + "battery_energy_ah", + "battery_soc", + "motor_power_consumption", + ].includes(key), + ); + + if (customFields.length > 0) { + // If custom fields are selected, fetch all telemetry data + fetchTelemetryDataInRange(startDate, endDate).then((allData) => { + if (!allData || !Array.isArray(allData)) return; + + const processedData = (allData as TelemetryData[]) + .map((dataPoint: any) => { + const result: any = { + timestamp: toCDT( + dataPoint.created_at || + dataPoint.gps?.rx_time || + new Date(), + ), + }; + + // Add regular fields + regularFields.forEach((field) => { + const firstUnderscore = field.indexOf("_"); + if (firstUnderscore !== -1) { + const category = field.substring(0, firstUnderscore); + const fieldName = field.substring(firstUnderscore + 1); + const value = getValueFromPath( + dataPoint, + `${category}.${fieldName}`, + ); + if (value !== undefined) { + result[field] = value; + } + } + }); + + // Calculate custom fields + customFields.forEach((field) => { + switch (field) { + case "net_power": + result[field] = calculateNetPower(dataPoint); + break; + case "motor_power": + result[field] = calculateMotorPower(dataPoint); + break; + case "total_solar_power": + result[field] = calculateTotalSolarPower(dataPoint); + break; + case "mppt_sum": + result[field] = + dataPoint.mppt1.output_v + + dataPoint.mppt2.output_v + + dataPoint.mppt3.output_v; + break; + case "net_power": + // Calculate battery power: voltage * current + // Only include if both voltage and current are non-zero + const voltage = dataPoint.battery?.main_bat_v; + const current = dataPoint.battery?.main_bat_c; + if (voltage && current) { + result[field] = voltage * current; + } else { + result[field] = 0; + } + break; + case "battery_energy_ah": + result[field] = calculateBatteryEnergyAh(dataPoint); + break; + case "battery_soc": + result[field] = calculateBatterySOC(dataPoint); + break; + case "motor_power_consumption": + const motorPowerConsumption = + calculateMotorPowerConsumption(dataPoint); + result[field] = motorPowerConsumption; + break; + } + }); + + return result; + }) + .filter((dataPoint) => { + // Filter out data points where any selected custom field has a value of 0 or null + return !customFields.some( + (field) => + dataPoint[field] === 0 || dataPoint[field] === null, + ); + }); + + setChartData(processedData); + }); + } else { + // If no custom fields, use the original approach + Promise.all( + selectedDataKeys.map((key) => + fetchTelemetryDataInRange(startDate, endDate, key).then( + (data) => ({ + key, + data, + }), + ), + ), + ).then((results) => { + const mergedData: { [timestamp: string]: any } = {}; + results.forEach(({ key, data }) => { + data?.forEach((point: any) => { + const cdtTimestamp = toCDT(point.timestamp); + const ts = cdtTimestamp.toISOString(); + if (!mergedData[ts]) + mergedData[ts] = { timestamp: cdtTimestamp }; + mergedData[ts][key] = point.value; + }); + }); + const mergedArray = Object.values(mergedData).sort( + (a: any, b: any) => + new Date(a.timestamp).getTime() - + new Date(b.timestamp).getTime(), + ); + setChartData(mergedArray); + }); + } + } + }, 2000); + setRefreshInterval(interval); + + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedDataKeys, startDate, endDate]); + + // Filter chart data based on Y trim values + const filteredChartData = chartData.filter((dataPoint) => { + // Check if any of the selected data keys have values outside the trim range + for (const key of selectedDataKeys) { + const value = dataPoint[key]; + if (value !== undefined && value !== null) { + if (minYTrim !== undefined && value < minYTrim) return false; + if (maxYTrim !== undefined && value > maxYTrim) return false; + } + } + return true; + }); + + // Generate dynamic chart + const chartConfig = generateChartConfig(selectedDataKeys, lineColors); + + // Check for validation errors and return early if any exist + const validationError = getValidationError(); + if (validationError) { + return validationError; + } + + const [integrals, setintegrals] = useState([]); + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + setintegrals(calcIntegrals); + } + }, [chartData]); + + useEffect(() => { + let calcIntegrals = []; + for (let i = 0; i < selectedDataKeys.length; i++) { + calcIntegrals.push(integrateData(chartData, selectedDataKeys[i])); + setintegrals(calcIntegrals); + } + }, [chartData]); + + return ( +
+
+
+ + + + + + {selectGroups.map((group) => ( +
+ {group.label} + + {group.options.map((option) => ( + { + const value = option.value.replace(".", "_"); + let updatedKeys: string[]; + + if (multiselectEnabled) { + // Multiselect mode: add/remove from array + updatedKeys = checked + ? [...selectedDataKeys, value] + : selectedDataKeys.filter((key) => key !== value); + } else { + // Single select mode: replace selection + updatedKeys = checked ? [value] : []; + } + + handleValueChange(updatedKeys); + }} + > + {option.label} + + ))} +
+ ))} +
+
+
+ { + setMultiselectEnabled(checked as boolean); + // If disabling multiselect and multiple items are selected, keep only the first one + if (!checked && selectedDataKeys.length > 1) { + setSelectedDataKeys([selectedDataKeys[0]]); + } + }} + /> + +
+
+
+
+ + + + + + { + setStartDate(date); + setOpen1(false); + }} + /> + + +
+
+ { + if (startDate) { + const newDate = new Date(startDate); + const [hours, minutes] = e.target.value.split(":"); + newDate.setHours(parseInt(hours)); + newDate.setMinutes(parseInt(minutes)); + setStartDate(newDate); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + /> +
+
+
+
+ + + + + + { + setEndDate(date); + setOpen2(false); + }} + /> + + +
+
+ { + if (endDate) { + const newDate = new Date(endDate); + const [hours, minutes] = e.target.value.split(":"); + newDate.setHours(parseInt(hours)); + newDate.setMinutes(parseInt(minutes)); + setEndDate(newDate); + } + }} + className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" + /> +
+
+
+ + {/* Y-Axis Trim Controls and Export */} +
+
+ + { + const value = e.target.value; + setMinYTrim(value === "" ? undefined : parseFloat(value)); + }} + className="w-32" + /> +
+
+ + { + const value = e.target.value; + setMaxYTrim(value === "" ? undefined : parseFloat(value)); + }} + className="w-32" + /> +
+
+ + + + + + + + Export Telemetry Data + + This will download a CSV file containing{" "} + {filteredChartData.length} data points for the selected fields + from {startDate?.toLocaleDateString()} to{" "} + {endDate?.toLocaleDateString()}. + {selectedDataKeys.length > 0 && ( + + Selected fields:{" "} + {selectedDataKeys + .map((key) => getLabelFromDataKey(key)) + .join(", ")} + + )} + + + + Cancel + + Download CSV + + + + +
+
+ +

+ + + + { + const date = value instanceof Date ? value : new Date(value); + return date.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + }} + /> + + Number.parseFloat(value.toFixed(1)).toString() + } + /> + { + // Get the timestamp from the payload data + if (payload && payload.length > 0 && payload[0].payload) { + const timestamp = payload[0].payload.timestamp; + try { + const date = new Date(timestamp); + if (!isNaN(date.getTime())) { + return date.toLocaleString(); + } + } catch (error) { + console.error("Error parsing timestamp:", error); + } + } + return String(value); + }} + /> + } + /> + } /> + {selectedDataKeys.map((key) => { + return ( + + ); + })} + + +
+
+ {selectedDataKeys.map((key, index) => { + const value = integrals[index]; + const title = + "Integral Value of " + + getLabelFromDataKey(key) + + " " + + Number(value).toFixed(2); + return ( + + ); + })} +
+ {selectedDataKeys.map((key, index) => { + return ( +
+ {key} : {integrals[index]} +
+ ); + })} +
+ ); }