correct data queries

This commit is contained in:
Syasya 2025-08-20 09:07:32 +08:00
parent 0885771131
commit a82e62b9b4
2 changed files with 269 additions and 114 deletions

View File

@ -19,6 +19,7 @@ import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css'; import 'react-datepicker/dist/react-datepicker.css';
import './datepicker-dark.css'; // custom dark mode styles import './datepicker-dark.css'; // custom dark mode styles
ChartJS.register(zoomPlugin); ChartJS.register(zoomPlugin);
interface TimeSeriesEntry { interface TimeSeriesEntry {
@ -30,9 +31,48 @@ interface EnergyLineChartProps {
siteId: string; 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( function groupTimeSeries(
data: TimeSeriesEntry[], data: TimeSeriesEntry[],
mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly' mode: 'day' | 'daily' | 'weekly' | 'monthly' | 'yearly',
agg: 'mean' | 'max' | 'sum' = 'mean'
): TimeSeriesEntry[] { ): TimeSeriesEntry[] {
const groupMap = new Map<string, number[]>(); const groupMap = new Map<string, number[]>();
@ -41,19 +81,22 @@ function groupTimeSeries(
let key = ''; let key = '';
switch (mode) { switch (mode) {
case 'day': case 'day': {
const local = new Date(date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })); const local = new Date(
const hour = local.getHours(); date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })
const minute = local.getMinutes() < 30 ? '00' : '30'; );
const adjusted = new Date(local.setMinutes(minute === '00' ? 0 : 30, 0)); // zero seconds const minute = local.getMinutes() < 30 ? 0 : 30;
key = adjusted.toISOString(); // ✅ full timestamp key local.setMinutes(minute, 0, 0);
key = local.toISOString();
break; break;
}
case 'daily': case 'daily':
key = date.toLocaleDateString('en-MY', { key = date.toLocaleDateString('en-MY', {
timeZone: 'Asia/Kuala_Lumpur', timeZone: 'Asia/Kuala_Lumpur',
weekday: 'short', weekday: 'short',
day: '2-digit', day: '2-digit',
month: 'short', month: 'short',
year: 'numeric',
}); });
break; break;
case 'weekly': case 'weekly':
@ -71,12 +114,19 @@ function groupTimeSeries(
groupMap.get(key)!.push(entry.value); groupMap.get(key)!.push(entry.value);
} }
return Array.from(groupMap.entries()).map(([time, values]) => ({ return Array.from(groupMap.entries()).map(([time, values]) => {
time, if (agg === 'sum') {
value: values.reduce((sum, v) => sum + v, 0), 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 EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
const chartRef = useRef<any>(null); const chartRef = useRef<any>(null);
const [viewMode, setViewMode] = useState<'day' | 'daily' | 'weekly' | 'monthly' | 'yearly'>('day'); 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 [selectedDate, setSelectedDate] = useState(new Date());
const [forecast, setForecast] = useState<TimeSeriesEntry[]>([]); const [forecast, setForecast] = useState<TimeSeriesEntry[]>([]);
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() { function useIsDarkMode() {
const [isDark, setIsDark] = useState(() => const [isDark, setIsDark] = useState(() =>
typeof document !== 'undefined' typeof document !== 'undefined'
@ -140,7 +278,7 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
setGeneration(res.generation); setGeneration(res.generation);
// ⬇️ ADD THIS here — fetch forecast // ⬇️ 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]; const selectedDateStr = selectedDate.toISOString().split('T')[0];
setForecast( setForecast(
@ -160,9 +298,40 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
fetchData(); fetchData();
}, [siteId, viewMode, selectedDate]); }, [siteId, viewMode, selectedDate]);
const groupedConsumption = groupTimeSeries(consumption, viewMode); const isEnergyView = viewMode !== 'day';
const groupedGeneration = groupTimeSeries(generation, viewMode);
const groupedForecast = groupTimeSeries(forecast, viewMode); // 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])); 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 consumptionColor = isDark ? '#B80F0A' : '#EF4444'; // Example: Brighter red for dark mode
const generationColor = isDark ? '#48A860' : '#22C55E'; // Example: Brighter green 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 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 = { const data = {
labels: filteredLabels.map(formatLabel), labels: filteredLabels.map(formatLabel),
@ -300,6 +471,13 @@ const forecastColor = '#fcd913'; // A golden yellow that works well in both mode
bodyColor: axisColor, bodyColor: axisColor,
borderColor: isDark ? '#444' : '#ccc', borderColor: isDark ? '#444' : '#ccc',
borderWidth: 1, borderWidth: 1,
callbacks: {
label: (ctx: any) => {
const dsLabel = ctx.dataset.label || '';
const val = ctx.parsed.y;
return `${dsLabel}: ${val?.toFixed(2)} ${yUnit}`;
},
},
}, },
}, },
scales: { scales: {
@ -326,12 +504,7 @@ const forecastColor = '#fcd913'; // A golden yellow that works well in both mode
y: { y: {
beginAtZero: true, beginAtZero: true,
suggestedMax: yAxisSuggestedMax, suggestedMax: yAxisSuggestedMax,
title: { title: { display: true, text: yTitle, color: axisColor, font: { weight: 'normal' as const } },
display: true,
text: 'Power (kW)',
color: axisColor,
font: { weight: 'normal' as const },
},
ticks: { ticks: {
color: axisColor, color: axisColor,
}, },

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { import {
BarChart, BarChart,
Bar, Bar,
@ -9,46 +9,25 @@ import {
Legend, Legend,
} from 'recharts'; } from 'recharts';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { fetchPowerTimeseries } from '@/app/utils/api'; import { fetchMonthlyKpi, type MonthlyKPI } from '@/app/utils/api';
interface MonthlyBarChartProps { interface MonthlyBarChartProps {
siteId: string; siteId: string;
} }
interface TimeSeriesEntry { const getLastNMonthKeys = (n: number): string[] => {
time: string; const out: string[] = [];
value: number; const now = new Date();
} // include current month, go back n-1 months
for (let i = 0; i < n; i++) {
const groupTimeSeries = ( const d = new Date(now.getFullYear(), now.getMonth() - (n - 1 - i), 1);
data: TimeSeriesEntry[], const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM
mode: 'monthly' out.push(key);
): TimeSeriesEntry[] => {
const groupMap = new Map<string, number[]>();
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);
} }
return out;
return Array.from(groupMap.entries()).map(([time, values]) => ({
time,
value: values.reduce((sum, v) => sum + v, 0),
}));
}; };
function useIsDarkMode() {
const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => {
const [chartData, setChartData] = useState<
{ month: string; consumption: number; generation: number }[]
>([]);
const [loading, setLoading] = useState(true);
function useIsDarkMode() {
const [isDark, setIsDark] = useState(() => const [isDark, setIsDark] = useState(() =>
typeof document !== 'undefined' typeof document !== 'undefined'
? document.body.classList.contains('dark') ? document.body.classList.contains('dark')
@ -58,71 +37,77 @@ const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => {
useEffect(() => { useEffect(() => {
const check = () => setIsDark(document.body.classList.contains('dark')); const check = () => setIsDark(document.body.classList.contains('dark'));
check(); check();
// Listen for class changes on <body>
const observer = new MutationObserver(check); const observer = new MutationObserver(check);
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
return isDark; return isDark;
} }
const isDark = useIsDarkMode(); const MonthlyBarChart: React.FC<MonthlyBarChartProps> = ({ siteId }) => {
const [chartData, setChartData] = useState<
{ month: string; consumption: number; generation: number }[]
>([]);
const [loading, setLoading] = useState(true);
const consumptionColor = isDark ? '#ba8e23' : '#003049'; const isDark = useIsDarkMode();
const generationColor = isDark ? '#fcd913' : '#669bbc'; const consumptionColor = isDark ? '#ba8e23' : '#003049';
const generationColor = isDark ? '#fcd913' : '#669bbc';
const monthKeys = useMemo(() => getLastNMonthKeys(6), []);
useEffect(() => { useEffect(() => {
if (!siteId) return; if (!siteId) return;
const fetchMonthlyData = async () => { const load = async () => {
setLoading(true); setLoading(true);
const start = '2025-01-01T00:00:00+08:00';
const end = '2025-12-31T23:59:59+08:00';
try { 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'); // Map to chart rows; default nulls to 0 for stacking/tooltip friendliness
const groupedGeneration = groupTimeSeries(res.generation, 'monthly'); 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<string, { consumption: number; generation: number }>(); setChartData(rows);
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([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchMonthlyData(); load();
}, [siteId]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [siteId]); // monthKeys are stable via useMemo
if (loading || !siteId || chartData.length === 0) { if (loading || !siteId || chartData.length === 0) {
return ( return (
@ -140,7 +125,7 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
<div className="bg-white p-3 rounded-lg dark:bg-rtgray-800 dark:text-white-light"> <div className="bg-white p-3 rounded-lg dark:bg-rtgray-800 dark:text-white-light">
<div className="h-[200px] w-full"> <div className="h-[200px] w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} > <BarChart data={chartData}>
<XAxis <XAxis
dataKey="month" dataKey="month"
tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }} tick={{ fontSize: 10, fill: isDark ? '#fff' : '#222' }}
@ -152,15 +137,15 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
axisLine={{ stroke: isDark ? '#fff' : '#222' }} axisLine={{ stroke: isDark ? '#fff' : '#222' }}
tickLine={{ stroke: isDark ? '#fff' : '#222' }} tickLine={{ stroke: isDark ? '#fff' : '#222' }}
label={{ label={{
value: 'Power (kW)', // <-- Y-axis label value: 'Energy (kWh)', // fixed: units are kWh
angle: -90, // Vertical text angle: -90,
position: 'insideLeft', // Position inside the chart area position: 'insideLeft',
style: { style: {
textAnchor: 'middle', textAnchor: 'middle',
fill: isDark ? '#fff' : '#222', fill: isDark ? '#fff' : '#222',
fontSize: 12, fontSize: 12,
}, },
}} }}
/> />
<Tooltip <Tooltip
formatter={(value: number) => [`${value.toFixed(2)} kWh`]} formatter={(value: number) => [`${value.toFixed(2)} kWh`]}
@ -174,15 +159,11 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
color: isDark ? '#fff' : '#222', color: isDark ? '#fff' : '#222',
}} }}
cursor={{ cursor={{
fill: isDark ? '#808080' : '#e0e7ef', // dark mode bg, light mode bg fill: isDark ? '#808080' : '#e0e7ef',
fillOpacity: isDark ? 0.6 : 0.3, // adjust opacity as you like fillOpacity: isDark ? 0.6 : 0.3,
}}
/>
<Legend
wrapperStyle={{
color: isDark ? '#fff' : '#222',
}} }}
/> />
<Legend wrapperStyle={{ color: isDark ? '#fff' : '#222' }} />
<Bar dataKey="consumption" fill={consumptionColor} name="Consumption (kWh)" /> <Bar dataKey="consumption" fill={consumptionColor} name="Consumption (kWh)" />
<Bar dataKey="generation" fill={generationColor} name="Generation (kWh)" /> <Bar dataKey="generation" fill={generationColor} name="Generation (kWh)" />
</BarChart> </BarChart>
@ -194,3 +175,4 @@ const generationColor = isDark ? '#fcd913' : '#669bbc';
export default MonthlyBarChart; export default MonthlyBarChart;