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 (
+
+
+
+
+
+
+
+ {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",
+ ]}
+ />
+
+
+
+
+ ) : (
+
+
+
+
+ | Período |
+ Início |
+ Fim |
+ Total |
+
+
+
+
+ {buckets.map((bucket) => (
+
+ |
+ {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 (
+
+ );
+}
+
+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)}
+ />
>
);
}