feature/syasya/testlayout #7
@ -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,
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
 | 
				
			|||||||
@ -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;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user