From 418f23586b50ebc33f2627b0f62ddc4b960881b1 Mon Sep 17 00:00:00 2001 From: Syasya Date: Wed, 27 Aug 2025 15:44:31 +0800 Subject: [PATCH] link to gdrive + granularity fixes --- app/(admin)/adminDashboard/page.tsx | 14 +- components/dashboards/EnergyLineChart.tsx | 510 +++++++++------------- 2 files changed, 229 insertions(+), 295 deletions(-) diff --git a/app/(admin)/adminDashboard/page.tsx b/app/(admin)/adminDashboard/page.tsx index df8d6f2..4aebff4 100644 --- a/app/(admin)/adminDashboard/page.tsx +++ b/app/(admin)/adminDashboard/page.tsx @@ -459,9 +459,21 @@ useEffect(() => { />
- + + + View Excel Logs +
)} diff --git a/components/dashboards/EnergyLineChart.tsx b/components/dashboards/EnergyLineChart.tsx index 78460e7..1351755 100644 --- a/components/dashboards/EnergyLineChart.tsx +++ b/components/dashboards/EnergyLineChart.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useState } from 'react'; +import React, { useRef, useEffect, useState, useCallback } from 'react'; import { Line } from 'react-chartjs-2'; import ChartJS from 'chart.js/auto'; import zoomPlugin from 'chartjs-plugin-zoom'; @@ -14,14 +14,11 @@ import { endOfYear, } from 'date-fns'; import { fetchPowerTimeseries, fetchForecast } from '@/app/utils/api'; -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 './datepicker-dark.css'; import 'chartjs-adapter-date-fns'; - - ChartJS.register(zoomPlugin); interface TimeSeriesEntry { @@ -39,7 +36,6 @@ function powerSeriesToEnergySeries( ): TimeSeriesEntry[] { if (!data?.length) return []; - // Ensure ascending by time const sorted = [...data].sort( (a, b) => new Date(a.time).getTime() - new Date(b.time).getTime() ); @@ -50,23 +46,18 @@ function powerSeriesToEnergySeries( for (let i = 0; i < sorted.length; i++) { const t0 = new Date(sorted[i].time).getTime(); const p0 = sorted[i].value; // kW - let deltaMs: number; if (i < sorted.length - 1) { const t1 = new Date(sorted[i + 1].time).getTime(); deltaMs = Math.max(0, t1 - t0); if (deltaMs > 0) lastDeltaMs = deltaMs; } else { - // For the last point, assume previous cadence or a guess deltaMs = lastDeltaMs ?? guessMinutes * 60 * 1000; } - const hours = deltaMs / (1000 * 60 * 60); const kwh = p0 * hours; // kW * h = kWh - out.push({ time: sorted[i].time, value: kwh }); } - return out; } @@ -76,19 +67,15 @@ function groupTimeSeries( agg: 'mean' | 'max' | 'sum' = 'mean' ): TimeSeriesEntry[] { const groupMap = new Map(); - for (const entry of data) { const date = new Date(entry.time); let key = ''; - 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 snappedMin = Math.floor(local.getMinutes() / 5) * 5; - local.setMinutes(snappedMin, 0, 0); + local.setSeconds(0, 0); key = local.toISOString(); break; } @@ -111,11 +98,9 @@ function groupTimeSeries( key = date.getFullYear().toString(); break; } - if (!groupMap.has(key)) groupMap.set(key, []); groupMap.get(key)!.push(entry.value); } - return Array.from(groupMap.entries()).map(([time, values]) => { if (agg === 'sum') { const sum = values.reduce((a, b) => a + b, 0); @@ -127,7 +112,6 @@ 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); @@ -142,15 +126,17 @@ function buildTimeGrid(start: Date, end: Date, stepMinutes = 5): string[] { const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const chartRef = useRef(null); const [viewMode, setViewMode] = useState<'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('day'); + const [selectedDate, setSelectedDate] = useState(new Date()); const [consumption, setConsumption] = useState([]); const [generation, setGeneration] = useState([]); - const [selectedDate, setSelectedDate] = useState(new Date()); const [forecast, setForecast] = useState([]); + const [startIndex, setStartIndex] = useState(0); + const [endIndex, setEndIndex] = useState(0); - const LIVE_REFRESH_MS = 300000; // 5min when viewing a single day - const SLOW_REFRESH_MS = 600000; // 10min for weekly/monthly/yearly + const LIVE_REFRESH_MS = 300000; + const SLOW_REFRESH_MS = 600000; - const fetchAndSet = React.useCallback(async () => { + const fetchAndSet = useCallback(async () => { const now = new Date(); let start: Date; let end: Date; @@ -185,17 +171,27 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd); setConsumption(res.consumption); setGeneration(res.generation); + + 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)) + .map(({ time, forecast }) => ({ + time, + value: forecast + })) + ); } catch (error) { console.error('Failed to fetch energy timeseries:', error); } }, [siteId, viewMode, selectedDate]); - // 3) Auto-refresh effect: initial load + interval (pauses when tab hidden) useEffect(() => { let timer: number | undefined; const tick = async () => { - // Avoid wasted calls when the tab is in the background if (!document.hidden) { await fetchAndSet(); } @@ -203,15 +199,12 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { timer = window.setTimeout(tick, ms); }; - // initial load fetchAndSet(); - // schedule next cycles timer = window.setTimeout(tick, viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS); - + const onVis = () => { if (!document.hidden) { - // kick immediately when user returns clearTimeout(timer); tick(); } @@ -241,169 +234,68 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { return isDark; } - useEffect(() => { - const now = new Date(); - let start: Date; - let end: Date; - - switch (viewMode) { - case 'day': - start = startOfDay(selectedDate); - end = endOfDay(selectedDate); - break; - case 'daily': - start = startOfWeek(now, { weekStartsOn: 1 }); - end = endOfWeek(now, { weekStartsOn: 1 }); - break; - case 'weekly': - start = startOfMonth(now); - end = endOfMonth(now); - break; - case 'monthly': - start = startOfYear(now); - end = endOfYear(now); - break; - case 'yearly': - start = new Date('2020-01-01'); - end = now; - break; - } - - const isoStart = start.toISOString(); - const isoEnd = end.toISOString(); - - const fetchData = async () => { - try { - const res = await fetchPowerTimeseries(siteId, isoStart, isoEnd); - setConsumption(res.consumption); - 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]; - - 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); - } - }; - - fetchData(); - }, [siteId, viewMode, selectedDate]); - const isEnergyView = viewMode !== 'day'; - // Convert to energy series for aggregated views - const consumptionForGrouping = isEnergyView - ? powerSeriesToEnergySeries(consumption, 30) - : consumption; + const consumptionForGrouping = isEnergyView ? powerSeriesToEnergySeries(consumption, 30) : consumption; + const generationForGrouping = isEnergyView ? powerSeriesToEnergySeries(generation, 30) : generation; + const forecastForGrouping = isEnergyView ? powerSeriesToEnergySeries(forecast, 60) : forecast; - const generationForGrouping = isEnergyView - ? powerSeriesToEnergySeries(generation, 30) - : generation; - - 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' - ); - - const groupedGeneration = groupTimeSeries( - generationForGrouping, - viewMode, - isEnergyView ? 'sum' : 'mean' - ); - - const groupedForecast = groupTimeSeries( - forecastForGrouping, - viewMode, - isEnergyView ? 'sum' : 'mean' - ); + const groupedConsumption = groupTimeSeries(consumptionForGrouping, viewMode, isEnergyView ? 'sum' : 'mean'); + const groupedGeneration = groupTimeSeries(generationForGrouping, viewMode, isEnergyView ? 'sum' : 'mean'); + const groupedForecast = groupTimeSeries(forecastForGrouping, viewMode, isEnergyView ? 'sum' : 'mean'); const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value])); + const consumptionMap = Object.fromEntries(groupedConsumption.map(d => [d.time, d.value])); + const generationMap = Object.fromEntries(groupedGeneration.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); - - // ---- 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 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 dayGrid = viewMode === 'day' + ? buildTimeGrid(startOfDay(selectedDate), endOfDay(selectedDate), 1) + : []; + + + 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])); + const hasDataAt = (t: string) => + t in consumptionMap || t in generationMap || t in forecastMap; - const [startIndex, setStartIndex] = useState(0); - const [endIndex, setEndIndex] = useState(allTimes.length - 1); + const firstAvailableIndex = allTimes.findIndex(hasDataAt); + const lastAvailableIndex = (() => { + for (let i = allTimes.length - 1; i >= 0; i--) { + if (hasDataAt(allTimes[i])) return i; + } + return -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 - ); + 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]); - + if (selectableIndices.length > 0) { + setStartIndex(selectableIndices[0]); + setEndIndex(selectableIndices[selectableIndices.length - 1]); + } else { + setStartIndex(0); + setEndIndex(allTimes.length > 0 ? allTimes.length - 1 : 0); + } + }, [allTimes, selectableIndices]); useEffect(() => { if (typeof window !== 'undefined') { @@ -411,44 +303,31 @@ const selectableIndices = } }, []); - useEffect(() => { - if (selectableIndices.length) { - const minIdx = selectableIndices[0]; - const maxIdx = selectableIndices[selectableIndices.length - 1]; - setStartIndex(minIdx); - setEndIndex(maxIdx); - } else { - setStartIndex(0); - setEndIndex(Math.max(0, allTimes.length - 1)); - } - // run whenever mode changes or the timeline changes -}, [viewMode, allTimes, firstAvailableIndex, lastAvailableIndex]); - - - const formatLabel = (key: string) => { + const formatLabel = useCallback((key: string) => { switch (viewMode) { case 'day': return new Date(key).toLocaleTimeString('en-GB', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - timeZone: 'Asia/Kuala_Lumpur', + hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Kuala_Lumpur', }); case 'monthly': return new Date(`${key}-01`).toLocaleString('en-GB', { month: 'short', year: 'numeric' }); case 'weekly': return key.replace('-', ' '); + case 'daily': + return new Date(key).toLocaleDateString('en-MY', { + weekday: 'short', day: '2-digit', month: 'short', year: 'numeric', + }); + case 'yearly': + return key; default: return key; } - }; + }, [viewMode]); const filteredLabels = allTimes.slice(startIndex, endIndex + 1); - - // ---- 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 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 is number => v !== null @@ -457,28 +336,23 @@ const selectableIndices = const yAxisSuggestedMax = maxValue * 1.15; const isDark = useIsDarkMode(); - const axisColor = isDark ? '#fff' : '#222'; + const consumptionColor = isDark ? '#B80F0A' : '#EF4444'; + const generationColor = isDark ? '#48A860' : '#22C55E'; + const yUnit = isEnergyView ? 'kWh' : 'kW'; + const yTitle = isEnergyView ? 'Energy (kWh)' : 'Power (kW)'; 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 + if (!chartArea) return hex; 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)'; - const data = { - labels: filteredLabels.map(formatLabel), + labels: filteredLabels, datasets: [ { label: 'Consumption', @@ -486,10 +360,10 @@ const selectableIndices = borderColor: consumptionColor, backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor), fill: true, - tension: 0.4, + tension: 0.2, spanGaps: true, - pointRadius: 1, // default is 3, make smaller - pointHoverRadius: 4, // a bit bigger on hover + pointRadius: 0, + pointHoverRadius: 4, borderWidth: 2, }, { @@ -498,10 +372,10 @@ const selectableIndices = borderColor: generationColor, backgroundColor: (ctx: any) => areaGradient(ctx, generationColor), fill: true, - tension: 0.4, + tension: 0.2, spanGaps: true, - pointRadius: 1, // default is 3, make smaller - pointHoverRadius: 4, // a bit bigger on hover + pointRadius: 0, + pointHoverRadius: 4, borderWidth: 2, }, { @@ -513,80 +387,141 @@ const selectableIndices = borderDash: [5, 5], fill: true, spanGaps: true, - pointRadius: 2, // default is 3, make smaller - pointHoverRadius: 4, // a bit bigger on hover + pointRadius: 2, + pointHoverRadius: 4, borderWidth: 2, - } + }, ], }; - const options = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'top', - labels: { - color: axisColor, // legend text color + const xClampMin = filteredLabels.length ? Date.parse(filteredLabels[0]) : undefined; +const xClampMax = filteredLabels.length ? Date.parse(filteredLabels[filteredLabels.length - 1]) : undefined; +const options = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + labels: { color: axisColor }, + }, + zoom: { + // ✅ limits, zoom and pan are siblings here + limits: { + x: { + min: xClampMin, + max: xClampMax, + // minRange: 60 * 1000, // optional: prevent zooming past 1 minute }, }, zoom: { - zoom: { - wheel: { enabled: true }, - pinch: { enabled: true }, - mode: 'x' as const, - }, - pan: { enabled: true, mode: 'x' as const }, + wheel: { enabled: true }, + pinch: { 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}`; - }, + 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}`; }, }, }, - scales: { - x: { - title: { - display: true, - color: axisColor, - text: - viewMode === 'day' - ? 'Time (HH:MM)' - : viewMode === 'daily' - ? 'Day' - : viewMode === 'weekly' - ? 'Week' - : viewMode === 'monthly' - ? 'Month' - : 'Year', - font: { weight: 'normal' as const }, - }, - ticks: { - color: axisColor, - }, - }, - y: { - beginAtZero: true, - suggestedMax: yAxisSuggestedMax, - title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, - ticks: { - color: axisColor, - }, + }, + scales: { + x: { + type: 'time', + min: xClampMin, + max: xClampMax, + time: { + unit: + viewMode === 'day' ? 'minute' + : viewMode === 'daily' ? 'day' + : viewMode === 'weekly' ? 'week' + : 'month', + tooltipFormat: 'dd MMM yyyy, HH:mm', + displayFormats: { + minute: 'HH:mm', + hour: 'HH:mm', + day: 'dd MMM', + week: 'w-yyyy', + month: 'MMM yyyy', + year: 'yyyy', + }, + }, + ticks: { + color: axisColor, + autoSkip: false, // we control density ourselves + source: 'labels', // use your label array + // IMPORTANT: use function syntax so "this" is the scale + callback: function ( + this: any, + tickValue: string | number, + index: number, + _ticks: any[] + ) { + // current visible range + const min = typeof this.min === 'number' ? this.min : Date.parse(this.min as string); + const max = typeof this.max === 'number' ? this.max : Date.parse(this.max as string); + const rangeMs = max - min; + + const ms = + typeof tickValue === 'number' ? tickValue : Date.parse(tickValue as string); + + // dynamic granularity + const H = 60 * 60 * 1000; + let stepMinutes = 60; // default when zoomed out + if (rangeMs <= 6 * H) stepMinutes = 5; + else if (rangeMs <= 12 * H) stepMinutes = 15; + + const d = new Date(ms); + // show only ticks aligned to stepMinutes (KL time) + const minutes = Number( + d.toLocaleString('en-GB', { timeZone: 'Asia/Kuala_Lumpur', minute: '2-digit' }).slice(-2) + ); + if (minutes % stepMinutes !== 0) return ''; + + return d.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'Asia/Kuala_Lumpur', + }); + }, + maxTicksLimit: 100, // safety +}, + + title: { + display: true, + color: axisColor, + text: + viewMode === 'day' ? 'Time' + : viewMode === 'daily' ? 'Day' + : viewMode === 'weekly' ? 'Week' + : viewMode === 'monthly' ? 'Month' + : 'Year', + font: { weight: 'normal' as const }, }, }, - } as const; + y: { + beginAtZero: true, + suggestedMax: yAxisSuggestedMax, + title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, + ticks: { color: axisColor }, + }, + }, +} as const; + const handleResetZoom = () => { chartRef.current?.resetZoom(); @@ -594,14 +529,13 @@ const selectableIndices = return (
-
+

Energy Consumption & Generation

-
{viewMode === 'day' && ( )} - - -