From b1bcf44f0f4568443dcbefd0b46560b89ec3866c Mon Sep 17 00:00:00 2001 From: litoral05 Date: Fri, 22 May 2026 17:26:31 +0100 Subject: [PATCH] add accumulated analytics with radiation integration --- .../components/AccumulatedHistoryModal.tsx | 300 ++++++++++++++++++ .../meteo/hooks/useAccumulatedHistory.ts | 75 +++++ src/features/meteo/pages/MeteoPage.tsx | 44 ++- 3 files changed, 408 insertions(+), 11 deletions(-) create mode 100644 src/features/meteo/components/AccumulatedHistoryModal.tsx create mode 100644 src/features/meteo/hooks/useAccumulatedHistory.ts diff --git a/src/features/meteo/components/AccumulatedHistoryModal.tsx b/src/features/meteo/components/AccumulatedHistoryModal.tsx new file mode 100644 index 0000000..36f1946 --- /dev/null +++ b/src/features/meteo/components/AccumulatedHistoryModal.tsx @@ -0,0 +1,300 @@ +import { useMemo, useState } from "react"; +import { BarChart3, Table2, X } from "lucide-react"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { ModuleSensorResponse } from "../../../types/meteo"; +import type { + AccumulatedBucket, + AccumulatedRange, +} from "../hooks/useAccumulatedHistory"; + +type Props = { + sensor: ModuleSensorResponse | null; + title: string; + theme: "dark" | "light"; + buckets: AccumulatedBucket[]; + loading: boolean; + range: AccumulatedRange; + onRangeChange: (range: AccumulatedRange) => void; + onClose: () => void; +}; + +const RANGE_OPTIONS: Array<{ label: string; value: AccumulatedRange }> = [ + { label: "7D", value: "7d" }, + { label: "30D", value: "30d" }, + { label: "Mês", value: "month" }, + { label: "Ano", value: "year" }, +]; + +export function AccumulatedHistoryModal({ + sensor, + title, + theme, + buckets, + loading, + range, + onRangeChange, + onClose, +}: Props) { + const isDark = theme === "dark"; + const [mode, setMode] = useState<"chart" | "table">("chart"); + + const stats = useMemo(() => { + if (buckets.length === 0) { + return { total: 0, average: 0, max: 0 }; + } + + const values = buckets.map((bucket) => bucket.total); + + return { + total: values.reduce((sum, value) => sum + value, 0), + average: values.reduce((sum, value) => sum + value, 0) / values.length, + max: Math.max(...values), + }; + }, [buckets]); + + if (!sensor) return null; + + const unit = sensor.unit ?? buckets[0]?.unit ?? ""; + + return ( +
+
+
+
+

+ Acumulado +

+ +

{title}

+ +

+ Chave: meteo.{sensor.key} +

+
+ + +
+ +
+
+
+ {RANGE_OPTIONS.map((option) => ( + + ))} + +
+ + + +
+
+ +
+ + + +
+ +
+ {loading ? ( + A carregar acumulados... + ) : buckets.length === 0 ? ( + Sem dados acumulados para este período. + ) : mode === "chart" ? ( + + + + + + + + + [ + formatValue(Number(value), unit), + "Acumulado", + ]} + /> + + + + + ) : ( +
+ + + + + + + + + + + + {buckets.map((bucket) => ( + + + + + + + ))} + +
PeríodoInícioFimTotal
+ {bucket.label} + + {formatDate(bucket.from)} + + {formatDate(bucket.to)} + + {formatValue(bucket.total, unit)} +
+
+ )} +
+
+
+
+
+ ); +} + +function EmptyState({ children }: { children: string }) { + return ( +
+ {children} +
+ ); +} + +function StatCard({ + theme, + label, + value, +}: { + theme: "dark" | "light"; + label: string; + value: string; +}) { + const isDark = theme === "dark"; + + return ( +
+

{label}

+

{value}

+
+ ); +} + +function toggleButtonClass(isDark: boolean, active: boolean) { + if (active) { + return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 px-3 py-2 text-xs font-semibold text-cyan-300"; + } + + return isDark + ? "rounded-lg border border-slate-700 px-3 py-2 text-xs font-semibold text-slate-200 hover:bg-slate-800" + : "rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-700 hover:bg-slate-100"; +} + +function formatValue(value: number, unit: string) { + return `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`; +} + +function formatDate(value: string) { + return new Date(value).toLocaleString("pt-PT", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} \ No newline at end of file diff --git a/src/features/meteo/hooks/useAccumulatedHistory.ts b/src/features/meteo/hooks/useAccumulatedHistory.ts new file mode 100644 index 0000000..504dd42 --- /dev/null +++ b/src/features/meteo/hooks/useAccumulatedHistory.ts @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; +import type { ModuleSensorResponse } from "../../../types/meteo"; + +export type AccumulatedBucket = { + label: string; + from: string; + to: string; + total: number; + unit?: string; +}; + +type AccumulatedRange = "7d" | "30d" | "month" | "year"; + +const BACKEND_URL = "http://localhost:18450"; + +export function useAccumulatedHistory( + sensor: ModuleSensorResponse | null, + range: AccumulatedRange, +) { + const [buckets, setBuckets] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!sensor) { + setBuckets([]); + return; + } + + const sensorKey = sensor.key; + const controller = new AbortController(); + + async function loadAccumulated() { + try { + setLoading(true); + + const params = new URLSearchParams({ + key: `meteo.${sensorKey}`, + range, + }); + + const response = await fetch( + `${BACKEND_URL}/api/historian/accumulated?${params.toString()}`, + { signal: controller.signal }, + ); + + if (!response.ok) { + throw new Error("Failed to load accumulated history"); + } + + const payload = (await response.json()) as AccumulatedBucket[]; + setBuckets(payload); + } catch (error) { + if (controller.signal.aborted) return; + + console.error("Failed to load accumulated history", error); + setBuckets([]); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + } + + loadAccumulated(); + + return () => controller.abort(); + }, [sensor?.key, range]); + + return { + buckets, + loading, + }; +} + +export type { AccumulatedRange }; \ No newline at end of file diff --git a/src/features/meteo/pages/MeteoPage.tsx b/src/features/meteo/pages/MeteoPage.tsx index 446e7b8..caf41ec 100644 --- a/src/features/meteo/pages/MeteoPage.tsx +++ b/src/features/meteo/pages/MeteoPage.tsx @@ -3,7 +3,6 @@ import { ChartNoAxesColumnIncreasing, Table2, CloudRain, - Compass, Droplets, MoreHorizontal, Radio, @@ -17,6 +16,11 @@ import { useMeteoModuleStream } from "../hooks/useMeteoModuleStream"; import { MeteoHistoryModal } from "../components/MeteoHistoryModal"; import type { ModuleSensorResponse } from "../../../types/meteo"; import { useMeteoHistory } from "../hooks/useMeteoHistory"; +import { AccumulatedHistoryModal } from "../components/AccumulatedHistoryModal"; +import { + useAccumulatedHistory, + type AccumulatedRange, +} from "../hooks/useAccumulatedHistory"; type MeteoPageProps = { theme: "dark" | "light"; @@ -59,13 +63,22 @@ export function MeteoPage({ theme }: MeteoPageProps) { const isRaining = rainValue !== null && rainValue > 0; const [openMenu, setOpenMenu] = useState(null); - const [selectedTable, setSelectedTable] = useState<{ - title: string; - sensors: ModuleSensorResponse[]; - } | null>(null); const meteoHistory = useMeteoHistory(selectedSensor); + const [selectedAccumulated, setSelectedAccumulated] = useState<{ + title: string; + sensor: ModuleSensorResponse | null; + } | null>(null); + + const [accumulatedRange, setAccumulatedRange] = + useState("7d"); + + const accumulatedHistory = useAccumulatedHistory( + selectedAccumulated?.sensor ?? null, + accumulatedRange, + ); + useEffect(() => { const samples: Array<[string, number | null]> = [ ["temperatura.exterior", numericValue(temperature)], @@ -185,9 +198,9 @@ export function MeteoPage({ theme }: MeteoPageProps) { label: "Ver acumulado", icon: , onClick: () => { - setSelectedTable({ + setSelectedAccumulated({ title: "Precipitação acumulada", - sensors: rainSensors, + sensor: rainSensor ?? null, }); setOpenMenu(null); @@ -254,11 +267,9 @@ export function MeteoPage({ theme }: MeteoPageProps) { label: "Ver acumulado", icon: , onClick: () => { - setSelectedTable({ + setSelectedAccumulated({ title: "Radiação solar acumulada", - sensors: sensors.filter((sensor) => - sensor.key.startsWith("radiacao."), - ), + sensor: radiation ?? null, }); setOpenMenu(null); @@ -287,6 +298,17 @@ export function MeteoPage({ theme }: MeteoPageProps) { onHoursChange={meteoHistory.setHours} onClose={() => setSelectedSensor(null)} /> + + setSelectedAccumulated(null)} + /> ); }