From 86682398db0127c9acd79c9fb2e17047fc811083 Mon Sep 17 00:00:00 2001 From: Syasya Date: Wed, 27 Aug 2025 10:46:11 +0800 Subject: [PATCH] tidy up --- app/(auth)/login/page.tsx | 2 +- app/(auth)/register/page.tsx | 2 +- app/(defaults)/chint/inverters/[id]/page.tsx | 261 -------------- app/(defaults)/chint/inverters/page.tsx | 174 ---------- app/(defaults)/chint/page.tsx | 13 - app/(defaults)/chint/sites/page.tsx | 39 --- app/(defaults)/sungrow/plant/page.tsx | 105 ------ app/api/sungrow/site/route.ts | 22 -- components/dashboards/EnergyLineChart.tsx | 348 ++++++++++++------- package-lock.json | 17 + package.json | 1 + 11 files changed, 234 insertions(+), 750 deletions(-) delete mode 100644 app/(defaults)/chint/inverters/[id]/page.tsx delete mode 100644 app/(defaults)/chint/inverters/page.tsx delete mode 100644 app/(defaults)/chint/page.tsx delete mode 100644 app/(defaults)/chint/sites/page.tsx delete mode 100644 app/(defaults)/sungrow/plant/page.tsx delete mode 100644 app/api/sungrow/site/route.ts diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index b1dcf64..b0bad9b 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -11,7 +11,7 @@ export default function LoginPage() { const [ready, setReady] = useState(false); // gate to avoid UI flash // Use ONE client-exposed API env var everywhere - const API = process.env.NEXT_PUBLIC_FASTAPI_URL || 'http://127.0.0.1:8000'; + const API = process.env.NEXT_PUBLIC_FASTAPI_URL; useEffect(() => { let cancelled = false; diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 11b05b0..07b5574 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -55,7 +55,7 @@ const RegisterPage = (props: Props) => {
Already have an account ?{" "} SIGN IN diff --git a/app/(defaults)/chint/inverters/[id]/page.tsx b/app/(defaults)/chint/inverters/[id]/page.tsx deleted file mode 100644 index 4b64183..0000000 --- a/app/(defaults)/chint/inverters/[id]/page.tsx +++ /dev/null @@ -1,261 +0,0 @@ -'use client'; - -import PanelCodeHighlight from '@/components/panel-code-highlight' -import React, { Fragment, useEffect, useState } from 'react'; -import { Tab } from '@headlessui/react'; -import IconHome from '@/components/icon/icon-home'; -import IconUser from '@/components/icon/icon-user'; -import IconPhone from '@/components/icon/icon-phone'; -import { useRouter } from 'next/router'; -import { useParams } from 'next/navigation'; -import axios from 'axios'; - -type Props = {} - -const InverterViewPage = (props: Props) => { - const [isMounted, setIsMounted] = useState(false) - const [loading, setLoading] = useState(true) - const params = useParams() - const [inverter, setInverter] = useState({}) - - useEffect(() => { - setIsMounted(true); - fetchData() - }, []) - - const fetchData = async () => { - try { - if (!params || !params.id) { - throw new Error("Invalid params or params.id is missing"); - } - const res = await axios.get(`https://api-a.fomware.com.cn/asset/v1/list?type=2&key=${params.id.toString()}`, { - headers: { - "Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN - } - }) - console.log("res", res.data.data.devices[0]) - setInverter(res.data.data.devices[0]) - } catch (error) { - console.error("Error fetching data:", error); - } finally { - setLoading(false); - } - }; - - - return ( - <> - - {loading ?

Loading...

: ( - <> - -
- {isMounted && ( - - - - {({ selected }) => ( - - )} - - - {({ selected }) => ( - - )} - - - - -
-

Last Updated ( 2025-02-24 16:03:10 +0800 )

- -
-
-

Basic Information

-
-
- -
-

Model: {inverter.model}

-

SN: {inverter.sn}

-

Total Energy: {inverter.eTotalWithUnit}

-

Today Energy: {inverter.eTodayWithUnit}

-

Reactive Power: {inverter.lastRTP["Reactive Power"].value} var

-

Active Power: {inverter.activePowerWithUnit}

-

Inverter Mode: {inverter.lastRTP["Inverter Mode"].value}

-

Inner Temperature: {inverter.lastRTP["Inner Temperature"].value} °C

-

Create Time: {inverter.createdAtStr}

-

Modules: {inverter.moduleFw.map((item: {module:string, value:string}) => `${item.module}: ${item.value}`.trim()).join(", ")}

-
- -
-
- Chart -
-
- )} -
-
- - -
- {isMounted && ( - - - - {({ selected }) => ( - - )} - - - {({ selected }) => ( - - )} - - - {({ selected }) => ( - - )} - - - {({ selected }) => ( - - )} - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Voltage(V)Current(A)Power(W)
PV1/PV1{inverter.lastRTP["PV1 Voltage"] && inverter.lastRTP["PV1 Voltage"].value}{inverter.lastRTP["PV1 Current"] && inverter.lastRTP["PV1 Current"].value}{inverter.lastRTP["MPPT1 Power"] && inverter.lastRTP["MPPT1 Power"].value}
PV2/PV2{inverter.lastRTP["PV2 Voltage"] && inverter.lastRTP["PV2 Voltage"].value}{inverter.lastRTP["PV2 Current"] && inverter.lastRTP["PV2 Current"].value}{inverter.lastRTP["MPPT2 Power"] && inverter.lastRTP["MPPT2 Power"].value}
PV3/PV3{inverter.lastRTP["PV3 Voltage"] && inverter.lastRTP["PV3 Voltage"].value}{inverter.lastRTP["PV3 Current"] && inverter.lastRTP["PV3 Current"].value}{inverter.lastRTP["MPPT3 Power"] && inverter.lastRTP["MPPT3 Power"].value}
PV3/PV3{inverter.lastRTP["PV4 Voltage"] && inverter.lastRTP["PV4 Voltage"].value}{inverter.lastRTP["PV4 Current"] && inverter.lastRTP["PV4 Current"].value}{inverter.lastRTP["MPPT4 Power"] && inverter.lastRTP["MPPT4 Power"].value}
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Voltage(V)Current(A)Power(W)Frequency(Hz)
A{inverter.lastRTP["Phase L1 Voltage"] && inverter.lastRTP["Phase L1 Voltage"].value}{inverter.lastRTP["Phase L1 Current"] && inverter.lastRTP["Phase L1 Current"].value}{inverter.lastRTP["Phase L1 Power"] && inverter.lastRTP["Phase L1 Power"].value}{inverter.lastRTP["Phase L1 Frequency"] && inverter.lastRTP["Phase L1 Frequency"].value}
B{inverter.lastRTP["Phase L2 Voltage"] && inverter.lastRTP["Phase L2 Voltage"].value}{inverter.lastRTP["Phase L2 Current"] && inverter.lastRTP["Phase L2 Current"].value}{inverter.lastRTP["Phase L2 Power"] && inverter.lastRTP["Phase L2 Power"].value}{inverter.lastRTP["Phase L2 Frequency"] && inverter.lastRTP["Phase L2 Frequency"].value}
C{inverter.lastRTP["Phase L3 Voltage"] && inverter.lastRTP["Phase L3 Voltage"].value}{inverter.lastRTP["Phase L3 Current"] && inverter.lastRTP["Phase L3 Current"].value}{inverter.lastRTP["Phase L3 Power"] && inverter.lastRTP["Phase L3 Power"].value}{inverter.lastRTP["Phase L3 Frequency"] && inverter.lastRTP["Phase L3 Frequency"].value}
-
-
- -
-
-

Today import Energy: {inverter.lastRTP["Today import Energy"] && inverter.lastRTP["Today import Energy"].value} kWh

-

L1-N phase voltage of grid: {inverter.lastRTP["L1-N phase voltage of grid"] && inverter.lastRTP["L1-N phase voltage of grid"].value} V

-

L2-N phase voltage of grid: {inverter.lastRTP["L2-N phase voltage of grid"] && inverter.lastRTP["L2-N phase voltage of grid"].value} V

-

L3-N phase voltage of grid: {inverter.lastRTP["L3-N phase voltage of grid"] && inverter.lastRTP["L3-N phase voltage of grid"].value} V

-

Today export Energy: {inverter.lastRTP["Today export Energy"] && inverter.lastRTP["Today export Energy"].value} kWh

-

L1 current of grid: {inverter.lastRTP["L1 current of grid"] && inverter.lastRTP["L1 current of grid"].value} A

-

L2 current of grid: {inverter.lastRTP["L2 current of grid"] && inverter.lastRTP["L2 current of grid"].value} A

-

L3 current of grid: {inverter.lastRTP["L3 current of grid"] && inverter.lastRTP["L3 current of grid"].value} A

-

Accumulated energy of positive: {inverter.lastRTP["Accumulated energy of positive"] && inverter.lastRTP["Accumulated energy of positive"].value} kWh

-

Phase L1 watt of grid: {inverter.lastRTP["Phase L1 watt of grid"] && inverter.lastRTP["Phase L1 watt of grid"].value} KW

-

Phase L2 watt of grid: {inverter.lastRTP["Phase L2 watt of grid"] && inverter.lastRTP["Phase L2 watt of grid"].value} KW

-

Phase L3 watt of grid: {inverter.lastRTP["Phase L3 watt of grid"] && inverter.lastRTP["Phase L3 watt of grid"].value} KW

-

Accumulated energy of negative: {inverter.lastRTP["Accumulated energy of negative"] && inverter.lastRTP["Accumulated energy of negative"].value} kWh

-
-
-
- -
-
-

Today load Energy: {inverter.lastRTP["Today load Energy"] && inverter.lastRTP["Today load Energy"].value} kWh

-

L1-N phase voltage of load: {inverter.lastRTP["L1-N phase voltage of load"] && inverter.lastRTP["L1-N phase voltage of load"].value} V

-

L2-N phase voltage of load: {inverter.lastRTP["L2-N phase voltage of load"] && inverter.lastRTP["L2-N phase voltage of load"].value} V

-

L3-N phase voltage of load: {inverter.lastRTP["L3-N phase voltage of load"] && inverter.lastRTP["L3-N phase voltage of load"].value} V

-

Accumulated energy of load: {inverter.lastRTP["Accumulated energy of load"] && inverter.lastRTP["Accumulated energy of load"].value} kWh

-

L1 current of load: {inverter.lastRTP["L1 current of load"] && inverter.lastRTP["L1 current of load"].value} A

-

L2 current of load: {inverter.lastRTP["L2 current of load"] && inverter.lastRTP["L2 current of load"].value} A

-

L3 current of load: {inverter.lastRTP["L3 current of load"] && inverter.lastRTP["L3 current of load"].value} A

-
-
-
-
-
- )} -
- - )} - - - ) -} - -export default InverterViewPage diff --git a/app/(defaults)/chint/inverters/page.tsx b/app/(defaults)/chint/inverters/page.tsx deleted file mode 100644 index 891d534..0000000 --- a/app/(defaults)/chint/inverters/page.tsx +++ /dev/null @@ -1,174 +0,0 @@ -"use client"; -import IconTrashLines from '@/components/icon/icon-trash-lines'; -import PanelCodeHighlight from '@/components/panel-code-highlight'; -import ComponentsTablesSimple from '@/components/tables/components-tables-simple'; -import { formatUnixTimestamp } from '@/utils/helpers'; -import Tippy from '@tippyjs/react'; -import axios from 'axios'; -import React, { useEffect, useState } from 'react' - -// import ReactApexChart from 'react-apexcharts'; -import dynamic from 'next/dynamic'; -import Link from 'next/link'; -const ReactApexChart = dynamic(() => import('react-apexcharts'), { ssr: false }); - -type Props = {} - -const SungrowInverters = (props: Props) => { - const [inverters, setInverters] = useState([]) - const [loading, setLoading] = useState(true) - const [isMounted, setIsMounted] = useState(false) - - useEffect(() => { - setIsMounted(true); - const fetchData = async () => { - try { - const res = await axios.get("https://api-a.fomware.com.cn/asset/v1/list?type=2", { - headers: { - "Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN - } - }) - console.log("res", res.data.data.devices) - setInverters(res.data.data.devices) - } catch (error) { - console.error("Error fetching data:", error); - } finally { - setLoading(false); - } - }; - - fetchData() - }, []) - - - const chartConfigs: any = { - options: { - chart: { - height: 58, - type: 'line', - fontFamily: 'Nunito, sans-serif', - sparkline: { - enabled: true, - }, - dropShadow: { - enabled: true, - blur: 3, - color: '#009688', - opacity: 0.4, - }, - }, - stroke: { - curve: 'smooth', - width: 2, - }, - colors: ['#009688'], - grid: { - padding: { - top: 5, - bottom: 5, - left: 5, - right: 5, - }, - }, - tooltip: { - x: { - show: false, - }, - y: { - title: { - formatter: () => { - return ''; - }, - }, - }, - }, - }, - }; - - // inverter status 0: initial, 1: standby, 2: fault, 3: running, 5: offline, 9: shutdown, 10: unknown - - return ( -
- {loading ?

Loading...

: ( - -
- - - - - - - - - - - - - - - - - - {inverters.map((data) => ( - - - - - - - - - - - - - - ))} - -
Inverter NameSite NameGateway SNInverter StatusModelSNReal Time PowerE-TodayWeekDataCreated AtUpdated At
-
{data.name}
-
-
{data.siteName}
-
-
{data.gatewaySn}
-
-
- {data.statusLabel} -
-
-
{data.model}
-
-
{data.sn}
-
-
{data.activePowerWithUnit}
-
-
{data.eTodayWithUnit}
-
- {isMounted && ( - point.y) }]} - options={{ - ...chartConfigs.options, - xaxis: { categories: data.weekTrend.map((point: any) => point.x) }, - }} - type="line" - height={58} - width={'100%'} - /> - )} {formatUnixTimestamp(data.createdAt)}{formatUnixTimestamp(data.updatedAt)}
-
-
- )} -
- ) -} - -export default SungrowInverters diff --git a/app/(defaults)/chint/page.tsx b/app/(defaults)/chint/page.tsx deleted file mode 100644 index 92db891..0000000 --- a/app/(defaults)/chint/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import axios from 'axios'; -import { Metadata } from 'next'; -import React from 'react'; - -export const metadata: Metadata = { -}; - - -const SungrowIndex = async () => { - return
SungrowIndex
; -}; - -export default SungrowIndex; diff --git a/app/(defaults)/chint/sites/page.tsx b/app/(defaults)/chint/sites/page.tsx deleted file mode 100644 index c8c9cf7..0000000 --- a/app/(defaults)/chint/sites/page.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; -// app/(defaults)/sungrow/assets/page.tsx - -import ComponentsTablesSimple from "@/components/tables/components-tables-simple"; -import axios from "axios"; -import React, { useEffect, useState } from "react"; - -const SungrowAssets = () => { - const [sites, setSites] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const fetchData = async () => { - try { - const res = await axios.get("https://api-a.fomware.com.cn/site/v1/list", { - headers: { - "Authorization": "Bearer " + process.env.NEXT_PUBLIC_CHINT_TOKEN - } - }) - console.log("res", res.data.data.siteInfos) - setSites(res.data.data.siteInfos) - } catch (error) { - console.error("Error fetching data:", error); - } finally { - setLoading(false); - } - }; - - fetchData() - }, []) - - return ( -
- {loading ?

Loading...

: } -
- ) -} - -export default SungrowAssets; diff --git a/app/(defaults)/sungrow/plant/page.tsx b/app/(defaults)/sungrow/plant/page.tsx deleted file mode 100644 index 16ba0c6..0000000 --- a/app/(defaults)/sungrow/plant/page.tsx +++ /dev/null @@ -1,105 +0,0 @@ -"use client"; - -import IconTrashLines from '@/components/icon/icon-trash-lines'; -import PanelCodeHighlight from '@/components/panel-code-highlight'; -import ComponentsTablesSimple from '@/components/tables/components-tables-simple' -import { formatUnixTimestamp } from '@/utils/helpers'; -import Tippy from '@tippyjs/react'; -import axios from 'axios'; -import React, { useEffect, useState } from "react" - -type Props = {} - -const SungrowPlant = (props: Props) => { - const [sites, setSites] = useState([]) - const [loading, setLoading] = useState(true) - - - useEffect(() => { - const fetchSites = async () => { - try { - const res = await fetch("/api/sungrow/site") - const data = await res.json() - console.log("data", data) - setSites(data) - } catch (error) { - console.error("Error fetching inverters:", error) - } finally { - setLoading(false) - } - } - - fetchSites() - }, []) - - const statusLabels: Record = { - 0: "Offline", - 1: "Normal", - } - const plantTypeLabel: Record = { - 3: "Commercial PV", - 4: "Residential PV", - } - - - return ( -
- {loading ?

Loading...

: ( - -
- - - - - - - {/* - - - - - - - */} - - - - - {sites.map((data) => ( - - - - - {/* - - - - - - - */} - - - ))} - -
Site NameStatusPlant TypeInstalled PowerReal-time PowerYield TodayMonthly YieldAnnual YieldTotal YieldEquivalent HoursRemarksAction
-
{data.ps_name}
-
-
- {statusLabels[data.online_status] || "-"} -
-
{plantTypeLabel[data.ps_type] || "-"} - - - -
-
-
- )} -
- ) -} - -export default SungrowPlant diff --git a/app/api/sungrow/site/route.ts b/app/api/sungrow/site/route.ts deleted file mode 100644 index aa99e66..0000000 --- a/app/api/sungrow/site/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextResponse } from "next/server"; -import axios from "axios"; - -export async function GET() { - try { - const res = await axios.post("https://gateway.isolarcloud.com.hk/openapi/platform/queryPowerStationList", { - "page": 1, - "size": 10, - "appkey": `${process.env.SUNGROW_APP_KEY}` - } ,{ - headers: { - "Authorization": `Bearer ${process.env.SUNGROW_ACCESS_TOKEN}`, - "x-access-key": `${process.env.SUNGROW_SECRET_KEY}` - } - }) - // console.log("res", res.data) - return NextResponse.json(res.data.result_data.pageList) - } catch (error) { - console.error("API fetch error:", error); - return NextResponse.json({ error: "Failed to fetch inverters" }, { status: 500 }); - } -} diff --git a/components/dashboards/EnergyLineChart.tsx b/components/dashboards/EnergyLineChart.tsx index e719d2b..78460e7 100644 --- a/components/dashboards/EnergyLineChart.tsx +++ b/components/dashboards/EnergyLineChart.tsx @@ -18,6 +18,8 @@ import { color } from 'html2canvas/dist/types/css/types/color'; import DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; import './datepicker-dark.css'; // custom dark mode styles +import 'chartjs-adapter-date-fns'; + ChartJS.register(zoomPlugin); @@ -68,7 +70,6 @@ function powerSeriesToEnergySeries( return out; } - function groupTimeSeries( data: TimeSeriesEntry[], mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly', @@ -82,11 +83,12 @@ function groupTimeSeries( switch (mode) { case 'day': { + // Snap to 5-minute buckets in local (KL) time const local = new Date( date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }) ); - const minute = local.getMinutes() < 30 ? 0 : 30; - local.setMinutes(minute, 0, 0); + const snappedMin = Math.floor(local.getMinutes() / 5) * 5; + local.setMinutes(snappedMin, 0, 0); key = local.toISOString(); break; } @@ -125,7 +127,17 @@ function groupTimeSeries( }); } - +// ---- NEW: build a 5-minute time grid for the day view +function buildTimeGrid(start: Date, end: Date, stepMinutes = 5): string[] { + const grid: string[] = []; + const t = new Date(start); + t.setSeconds(0, 0); + while (t.getTime() <= end.getTime()) { + grid.push(new Date(t).toISOString()); + t.setTime(t.getTime() + stepMinutes * 60 * 1000); + } + return grid; +} const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const chartRef = useRef(null); @@ -138,7 +150,6 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const LIVE_REFRESH_MS = 300000; // 5min when viewing a single day const SLOW_REFRESH_MS = 600000; // 10min for weekly/monthly/yearly - const fetchAndSet = React.useCallback(async () => { const now = new Date(); let start: Date; @@ -174,15 +185,6 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd); setConsumption(res.consumption); setGeneration(res.generation); - - // Forecast only needs updating for the selected day - const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 25.67); - const selectedDateStr = selectedDate.toISOString().split('T')[0]; - setForecast( - forecastData - .filter(({ time }: any) => time.startsWith(selectedDateStr)) - .map(({ time, forecast }: any) => ({ time, value: forecast })) - ); } catch (error) { console.error('Failed to fetch energy timeseries:', error); } @@ -222,23 +224,22 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { }; }, [fetchAndSet, viewMode]); - function useIsDarkMode() { - const [isDark, setIsDark] = useState(() => - typeof document !== 'undefined' - ? document.body.classList.contains('dark') - : false - ); + const [isDark, setIsDark] = useState(() => + typeof document !== 'undefined' + ? document.body.classList.contains('dark') + : false + ); - useEffect(() => { - const check = () => setIsDark(document.body.classList.contains('dark')); - const observer = new MutationObserver(check); - observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); - return () => observer.disconnect(); - }, []); + useEffect(() => { + const check = () => setIsDark(document.body.classList.contains('dark')); + const observer = new MutationObserver(check); + observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); + return () => observer.disconnect(); + }, []); - return isDark; -} + return isDark; + } useEffect(() => { const now = new Date(); @@ -278,17 +279,17 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { setGeneration(res.generation); // ⬇️ ADD THIS here — fetch forecast - const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67); - const selectedDateStr = selectedDate.toISOString().split('T')[0]; + const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67); + const selectedDateStr = selectedDate.toISOString().split('T')[0]; - setForecast( - forecastData - .filter(({ time }) => time.startsWith(selectedDateStr)) // ✅ filter only selected date - .map(({ time, forecast }) => ({ - time, - value: forecast - })) - ); + setForecast( + forecastData + .filter(({ time }) => time.startsWith(selectedDateStr)) // ✅ filter only selected date + .map(({ time, forecast }) => ({ + time, + value: forecast + })) + ); } catch (error) { console.error('Failed to fetch energy timeseries:', error); @@ -300,48 +301,70 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const isEnergyView = viewMode !== 'day'; -// Convert to energy series for aggregated views -const consumptionForGrouping = isEnergyView - ? powerSeriesToEnergySeries(consumption, 30) - : consumption; + // Convert to energy series for aggregated views + const consumptionForGrouping = isEnergyView + ? powerSeriesToEnergySeries(consumption, 30) + : consumption; -const generationForGrouping = isEnergyView - ? powerSeriesToEnergySeries(generation, 30) - : generation; + const generationForGrouping = isEnergyView + ? powerSeriesToEnergySeries(generation, 30) + : generation; -const forecastForGrouping = isEnergyView - ? powerSeriesToEnergySeries(forecast, 60) // if forecast is hourly, guess 60 - : forecast; + const forecastForGrouping = isEnergyView + ? powerSeriesToEnergySeries(forecast, 60) // if forecast is hourly, guess 60 + : forecast; -// Group: sum for energy views, mean for day view -const groupedConsumption = groupTimeSeries( - consumptionForGrouping, - viewMode, - isEnergyView ? 'sum' : 'mean' -); + // Group: sum for energy views, mean for day view + const groupedConsumption = groupTimeSeries( + consumptionForGrouping, + viewMode, + isEnergyView ? 'sum' : 'mean' + ); -const groupedGeneration = groupTimeSeries( - generationForGrouping, - viewMode, - isEnergyView ? 'sum' : 'mean' -); + const groupedGeneration = groupTimeSeries( + generationForGrouping, + viewMode, + isEnergyView ? 'sum' : 'mean' + ); -const groupedForecast = groupTimeSeries( - forecastForGrouping, - viewMode, - isEnergyView ? 'sum' : 'mean' -); + const groupedForecast = groupTimeSeries( + forecastForGrouping, + viewMode, + isEnergyView ? 'sum' : 'mean' + ); const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value])); + const dataTimesDay = [ + ...groupedConsumption.map(d => Date.parse(d.time)), + ...groupedGeneration.map(d => Date.parse(d.time)), + ...groupedForecast.map(d => Date.parse(d.time)), +].filter(Number.isFinite).sort((a, b) => a - b); - const allTimes = Array.from(new Set([ - ...groupedConsumption.map(d => d.time), - ...groupedGeneration.map(d => d.time), - ...groupedForecast.map(d => d.time), -])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); - + // ---- CHANGED: use a 5-minute grid for day view + const dayGrid = + viewMode === 'day' + ? (() => { + const dayStart = startOfDay(selectedDate).getTime(); + const dayEnd = endOfDay(selectedDate).getTime(); + if (dataTimesDay.length) { + const minT = Math.max(dayStart, dataTimesDay[0]); + const maxT = Math.min(dayEnd, dataTimesDay[dataTimesDay.length - 1]); + return buildTimeGrid(new Date(minT), new Date(maxT), 5) + } + // no data → keep full day + return buildTimeGrid(new Date(dayStart), new Date(dayEnd), 5); + })() + : []; + + + const unionTimes = Array.from(new Set([ + ...groupedConsumption.map(d => d.time), + ...groupedGeneration.map(d => d.time), + ...groupedForecast.map(d => d.time), + ])).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()); + const allTimes = viewMode === 'day' ? dayGrid : unionTimes; const consumptionMap = Object.fromEntries(groupedConsumption.map(d => [d.time, d.value])); const generationMap = Object.fromEntries(groupedGeneration.map(d => [d.time, d.value])); @@ -349,7 +372,39 @@ const groupedForecast = groupTimeSeries( const [startIndex, setStartIndex] = useState(0); const [endIndex, setEndIndex] = useState(allTimes.length - 1); - + // after allTimes, consumptionMap, generationMap, forecastMap +const hasDataAt = (t: string) => + t in consumptionMap || t in generationMap || t in forecastMap; + +const firstAvailableIndex = allTimes.findIndex(hasDataAt); +const lastAvailableIndex = (() => { + for (let i = allTimes.length - 1; i >= 0; i--) { + if (hasDataAt(allTimes[i])) return i; + } + return -1; +})(); + +const selectableIndices = + firstAvailableIndex === -1 || lastAvailableIndex === -1 + ? [] + : Array.from( + { length: lastAvailableIndex - firstAvailableIndex + 1 }, + (_, k) => firstAvailableIndex + k + ); + + useEffect(() => { + if (selectableIndices.length === 0) { + setStartIndex(0); + setEndIndex(Math.max(0, allTimes.length - 1)); + return; + } + const minIdx = selectableIndices[0]; + const maxIdx = selectableIndices[selectableIndices.length - 1]; + setStartIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx)); + setEndIndex(prev => Math.min(Math.max(prev, minIdx), maxIdx)); +}, [viewMode, allTimes.length, firstAvailableIndex, lastAvailableIndex]); + + useEffect(() => { if (typeof window !== 'undefined') { import('hammerjs'); @@ -357,9 +412,18 @@ const groupedForecast = groupTimeSeries( }, []); useEffect(() => { + if (selectableIndices.length) { + const minIdx = selectableIndices[0]; + const maxIdx = selectableIndices[selectableIndices.length - 1]; + setStartIndex(minIdx); + setEndIndex(maxIdx); + } else { setStartIndex(0); - setEndIndex(allTimes.length - 1); - }, [viewMode, allTimes.length]); + setEndIndex(Math.max(0, allTimes.length - 1)); + } + // run whenever mode changes or the timeline changes +}, [viewMode, allTimes, firstAvailableIndex, lastAvailableIndex]); + const formatLabel = (key: string) => { switch (viewMode) { @@ -380,35 +444,38 @@ const groupedForecast = groupTimeSeries( }; const filteredLabels = allTimes.slice(startIndex, endIndex + 1); - const filteredConsumption = filteredLabels.map(t => consumptionMap[t] ?? 0); - const filteredGeneration = filteredLabels.map(t => generationMap[t] ?? 0); - const filteredForecast = filteredLabels.map(t => forecastMap[t] ?? null); + // ---- CHANGED: use nulls for missing buckets (not zeros) + const filteredConsumption = filteredLabels.map(t => (t in consumptionMap ? consumptionMap[t] : null)); + const filteredGeneration = filteredLabels.map(t => (t in generationMap ? generationMap[t] : null)); + const filteredForecast = filteredLabels.map(t => (t in forecastMap ? forecastMap[t] : null)); - const allValues = [...filteredConsumption, ...filteredGeneration].filter(v => v !== null) as number[]; + const allValues = [...filteredConsumption, ...filteredGeneration].filter( + (v): v is number => v !== null + ); const maxValue = allValues.length > 0 ? Math.max(...allValues) : 0; const yAxisSuggestedMax = maxValue * 1.15; const isDark = useIsDarkMode(); -const axisColor = isDark ? '#fff' : '#222'; + const axisColor = isDark ? '#fff' : '#222'; -function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) { - const { ctx: g, chartArea } = ctx.chart; - if (!chartArea) return hex; // initial render fallback - const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); - // top more opaque → bottom fades out - gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0')); - gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0')); - return gradient; -} + function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02) { + const { ctx: g, chartArea } = ctx.chart; + if (!chartArea) return hex; // initial render fallback + const gradient = g.createLinearGradient(0, chartArea.top, 0, chartArea.bottom); + // top more opaque → bottom fades out + gradient.addColorStop(0, hex + Math.floor(alphaTop * 255).toString(16).padStart(2, '0')); + gradient.addColorStop(1, hex + Math.floor(alphaBottom * 255).toString(16).padStart(2, '0')); + return gradient; + } -// Define colors for both light and dark modes -const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode -const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode -const forecastColor = '#fcd913'; // A golden yellow that works well in both modes -const yUnit = isEnergyView ? 'kWh' : 'kW'; -const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; + // Define colors for both light and dark modes + const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode + const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green for dark mode + const forecastColor = '#fcd913'; // A golden yellow that works well in both modes + const yUnit = isEnergyView ? 'kWh' : 'kW'; + const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; const data = { labels: filteredLabels.map(formatLabel), @@ -418,29 +485,38 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; data: filteredConsumption, borderColor: consumptionColor, backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor), - fill: true, // <-- fill under line + fill: true, tension: 0.4, spanGaps: true, + pointRadius: 1, // default is 3, make smaller + pointHoverRadius: 4, // a bit bigger on hover + borderWidth: 2, }, { label: 'Generation', data: filteredGeneration, borderColor: generationColor, backgroundColor: (ctx: any) => areaGradient(ctx, generationColor), - fill: true, // <-- fill under line + fill: true, tension: 0.4, spanGaps: true, + pointRadius: 1, // default is 3, make smaller + pointHoverRadius: 4, // a bit bigger on hover + borderWidth: 2, }, { - label: 'Forecasted Solar', - data: filteredForecast, - borderColor: '#fcd913', // orange - backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03), - tension: 0.4, - borderDash: [5, 5], // dashed line to distinguish forecast - fill: true, - spanGaps: true, - } + label: 'Forecasted Solar', + data: filteredForecast, + borderColor: '#fcd913', + backgroundColor: (ctx: any) => areaGradient(ctx, '#fcd913', 0.18, 0.03), + tension: 0.4, + borderDash: [5, 5], + fill: true, + spanGaps: true, + pointRadius: 2, // default is 3, make smaller + pointHoverRadius: 4, // a bit bigger on hover + borderWidth: 2, + } ], }; @@ -448,12 +524,12 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; responsive: true, maintainAspectRatio: false, plugins: { - legend: { - position: 'top', - labels: { - color: axisColor, // legend text color + legend: { + position: 'top', + labels: { + color: axisColor, // legend text color + }, }, - }, zoom: { zoom: { wheel: { enabled: true }, @@ -463,23 +539,23 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; pan: { enabled: true, mode: 'x' as const }, }, tooltip: { - enabled: true, - mode: 'index', - intersect: false, - backgroundColor: isDark ? '#232b3e' : '#fff', - titleColor: axisColor, - bodyColor: axisColor, - borderColor: isDark ? '#444' : '#ccc', - borderWidth: 1, - callbacks: { - label: (ctx: any) => { - const dsLabel = ctx.dataset.label || ''; - const val = ctx.parsed.y; - return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`; + enabled: true, + mode: 'index', + intersect: false, + backgroundColor: isDark ? '#232b3e' : '#fff', + titleColor: axisColor, + bodyColor: axisColor, + borderColor: isDark ? '#444' : '#ccc', + borderWidth: 1, + callbacks: { + label: (ctx: any) => { + const dsLabel = ctx.dataset.label || ''; + const val = ctx.parsed.y; + return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`; + }, + }, }, }, - }, - }, scales: { x: { title: { @@ -498,18 +574,17 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; font: { weight: 'normal' as const }, }, ticks: { - color: axisColor, - }, + color: axisColor, + }, }, y: { beginAtZero: true, suggestedMax: yAxisSuggestedMax, title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, ticks: { - color: axisColor, + color: axisColor, + }, }, - }, - }, } as const; @@ -519,7 +594,7 @@ const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; return (
-
+

Energy Consumption & Generation