diff --git a/components/dashboards/EnergyLineChart.tsx b/components/dashboards/EnergyLineChart.tsx index 7446d33..e719d2b 100644 --- a/components/dashboards/EnergyLineChart.tsx +++ b/components/dashboards/EnergyLineChart.tsx @@ -19,6 +19,7 @@ import DatePicker from 'react-datepicker'; import 'react-datepicker/dist/react-datepicker.css'; import './datepicker-dark.css'; // custom dark mode styles + ChartJS.register(zoomPlugin); interface TimeSeriesEntry { @@ -30,9 +31,48 @@ interface EnergyLineChartProps { siteId: string; } +function powerSeriesToEnergySeries( + data: TimeSeriesEntry[], + guessMinutes = 30 +): 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() + ); + + const out: TimeSeriesEntry[] = []; + let lastDeltaMs: number | null = null; + + 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; +} + + function groupTimeSeries( data: TimeSeriesEntry[], - mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly' + mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly', + agg: 'mean' | 'max' | 'sum' = 'mean' ): TimeSeriesEntry[] { const groupMap = new Map(); @@ -41,19 +81,22 @@ function groupTimeSeries( let key = ''; switch (mode) { - case 'day': - const local = new Date(date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })); - const hour = local.getHours(); - const minute = local.getMinutes() < 30 ? '00' : '30'; - const adjusted = new Date(local.setMinutes(minute === '00' ? 0 : 30, 0)); // zero seconds - key = adjusted.toISOString(); // ✅ full timestamp key + case 'day': { + const local = new Date( + date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }) + ); + const minute = local.getMinutes() < 30 ? 0 : 30; + local.setMinutes(minute, 0, 0); + key = local.toISOString(); break; + } case 'daily': key = date.toLocaleDateString('en-MY', { timeZone: 'Asia/Kuala_Lumpur', weekday: 'short', day: '2-digit', month: 'short', + year: 'numeric', }); break; case 'weekly': @@ -71,12 +114,19 @@ function groupTimeSeries( groupMap.get(key)!.push(entry.value); } - return Array.from(groupMap.entries()).map(([time, values]) => ({ - time, - value: values.reduce((sum, v) => sum + v, 0), - })); + return Array.from(groupMap.entries()).map(([time, values]) => { + if (agg === 'sum') { + const sum = values.reduce((a, b) => a + b, 0); + return { time, value: sum }; + } + const mean = values.reduce((a, b) => a + b, 0) / values.length; + const max = values.reduce((a, b) => (b > a ? b : a), -Infinity); + return { time, value: agg === 'max' ? max : mean }; + }); } + + const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const chartRef = useRef(null); const [viewMode, setViewMode] = useState<'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('day'); @@ -85,6 +135,94 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { const [selectedDate, setSelectedDate] = useState(new Date()); const [forecast, setForecast] = useState([]); + 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; + 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(); + + try { + 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); + } + }, [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(); + } + const ms = viewMode === 'day' ? LIVE_REFRESH_MS : SLOW_REFRESH_MS; + 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(); + } + }; + document.addEventListener('visibilitychange', onVis); + + return () => { + clearTimeout(timer); + document.removeEventListener('visibilitychange', onVis); + }; + }, [fetchAndSet, viewMode]); + + function useIsDarkMode() { const [isDark, setIsDark] = useState(() => typeof document !== 'undefined' @@ -140,7 +278,7 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { setGeneration(res.generation); // ⬇️ ADD THIS here — fetch forecast - const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 20.67); + const forecastData = await fetchForecast(3.15, 101.7, 37, 0, 30.67); const selectedDateStr = selectedDate.toISOString().split('T')[0]; setForecast( @@ -160,9 +298,40 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { fetchData(); }, [siteId, viewMode, selectedDate]); - const groupedConsumption = groupTimeSeries(consumption, viewMode); - const groupedGeneration = groupTimeSeries(generation, viewMode); - const groupedForecast = groupTimeSeries(forecast, viewMode); + const isEnergyView = viewMode !== 'day'; + +// Convert to energy series for aggregated views +const consumptionForGrouping = isEnergyView + ? powerSeriesToEnergySeries(consumption, 30) + : consumption; + +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 forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value])); @@ -238,6 +407,8 @@ function areaGradient(ctx: any, hex: string, alphaTop = 0.22, alphaBottom = 0.02 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), @@ -300,6 +471,13 @@ const forecastColor = '#fcd913'; // A golden yellow that works well in both mode 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: { @@ -326,12 +504,7 @@ const forecastColor = '#fcd913'; // A golden yellow that works well in both mode y: { beginAtZero: true, suggestedMax: yAxisSuggestedMax, - title: { - display: true, - text: 'Power (kW)', - color: axisColor, - font: { weight: 'normal' as const }, - }, + title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } }, ticks: { color: axisColor, }, diff --git a/components/dashboards/MonthlyBarChart.tsx b/components/dashboards/MonthlyBarChart.tsx index d68df4a..57e15e7 100644 --- a/components/dashboards/MonthlyBarChart.tsx +++ b/components/dashboards/MonthlyBarChart.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { BarChart, Bar, @@ -9,46 +9,25 @@ import { Legend, } from 'recharts'; import { format } from 'date-fns'; -import { fetchPowerTimeseries } from '@/app/utils/api'; - +import { fetchMonthlyKpi, type MonthlyKPI } from '@/app/utils/api'; interface MonthlyBarChartProps { siteId: string; } -interface TimeSeriesEntry { - time: string; - value: number; -} - -const groupTimeSeries = ( - data: TimeSeriesEntry[], - mode: 'monthly' -): TimeSeriesEntry[] => { - const groupMap = new Map(); - - for (const entry of data) { - const date = new Date(entry.time); - const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; - if (!groupMap.has(key)) groupMap.set(key, []); - groupMap.get(key)!.push(entry.value); +const getLastNMonthKeys = (n: number): string[] => { + const out: string[] = []; + const now = new Date(); + // include current month, go back n-1 months + for (let i = 0; i < n; i++) { + const d = new Date(now.getFullYear(), now.getMonth() - (n - 1 - i), 1); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM + out.push(key); } - - return Array.from(groupMap.entries()).map(([time, values]) => ({ - time, - value: values.reduce((sum, v) => sum + v, 0), - })); + return out; }; - - -const MonthlyBarChart: React.FC = ({ siteId }) => { - const [chartData, setChartData] = useState< - { month: string; consumption: number; generation: number }[] - >([]); - const [loading, setLoading] = useState(true); - - function useIsDarkMode() { +function useIsDarkMode() { const [isDark, setIsDark] = useState(() => typeof document !== 'undefined' ? document.body.classList.contains('dark') @@ -58,71 +37,77 @@ const MonthlyBarChart: React.FC = ({ siteId }) => { useEffect(() => { const check = () => setIsDark(document.body.classList.contains('dark')); check(); - - // Listen for class changes on const observer = new MutationObserver(check); observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); - return () => observer.disconnect(); }, []); return isDark; } -const isDark = useIsDarkMode(); +const MonthlyBarChart: React.FC = ({ siteId }) => { + const [chartData, setChartData] = useState< + { month: string; consumption: number; generation: number }[] + >([]); + const [loading, setLoading] = useState(true); -const consumptionColor = isDark ? '#ba8e23' : '#003049'; -const generationColor = isDark ? '#fcd913' : '#669bbc'; + const isDark = useIsDarkMode(); + const consumptionColor = isDark ? '#ba8e23' : '#003049'; + const generationColor = isDark ? '#fcd913' : '#669bbc'; + + const monthKeys = useMemo(() => getLastNMonthKeys(6), []); useEffect(() => { if (!siteId) return; - const fetchMonthlyData = async () => { + const load = async () => { setLoading(true); - const start = '2025-01-01T00:00:00+08:00'; - const end = '2025-12-31T23:59:59+08:00'; - try { - const res = await fetchPowerTimeseries(siteId, start, end); + // Fetch all 6 months in parallel + const results: MonthlyKPI[] = await Promise.all( + monthKeys.map((month) => + fetchMonthlyKpi({ + site: siteId, + month, + // consumption_topic: '...', // optional if your API needs it + // generation_topic: '...', // optional if your API needs it + }).catch((e) => { + // normalize failures to an error-shaped record so the chart can still render other months + return { + site: siteId, + month, + yield_kwh: null, + consumption_kwh: null, + grid_draw_kwh: null, + efficiency: null, + peak_demand_kw: null, + avg_power_factor: null, + load_factor: null, + error: String(e), + } as MonthlyKPI; + }) + ) + ); - const groupedConsumption = groupTimeSeries(res.consumption, 'monthly'); - const groupedGeneration = groupTimeSeries(res.generation, 'monthly'); + // Map to chart rows; default nulls to 0 for stacking/tooltip friendliness + const rows = results.map((kpi) => { + const monthLabel = format(new Date(`${kpi.month}-01`), 'MMM'); + return { + month: monthLabel, + consumption: kpi.consumption_kwh ?? 0, + generation: kpi.yield_kwh ?? 0, + }; + }); - const monthMap = new Map(); - - for (const entry of groupedConsumption) { - if (!monthMap.has(entry.time)) { - monthMap.set(entry.time, { consumption: 0, generation: 0 }); - } - monthMap.get(entry.time)!.consumption = entry.value; - } - - for (const entry of groupedGeneration) { - if (!monthMap.has(entry.time)) { - monthMap.set(entry.time, { consumption: 0, generation: 0 }); - } - monthMap.get(entry.time)!.generation = entry.value; - } - - const formatted = Array.from(monthMap.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, val]) => ({ - month: format(new Date(`${key}-01`), 'MMM'), - consumption: val.consumption, - generation: val.generation, - })); - - setChartData(formatted.slice(-6)); // last 6 months - } catch (error) { - console.error('Failed to fetch monthly power data:', error); - setChartData([]); + setChartData(rows); } finally { setLoading(false); } }; - fetchMonthlyData(); - }, [siteId]); + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [siteId]); // monthKeys are stable via useMemo if (loading || !siteId || chartData.length === 0) { return ( @@ -140,7 +125,7 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
- + [`${value.toFixed(2)} kWh`]} @@ -174,15 +159,11 @@ const generationColor = isDark ? '#fcd913' : '#669bbc'; color: isDark ? '#fff' : '#222', }} cursor={{ - fill: isDark ? '#808080' : '#e0e7ef', // dark mode bg, light mode bg - fillOpacity: isDark ? 0.6 : 0.3, // adjust opacity as you like - }} - /> - + @@ -194,3 +175,4 @@ const generationColor = isDark ? '#fcd913' : '#669bbc'; export default MonthlyBarChart; +