diff --git a/src/app/App.tsx b/src/app/App.tsx
index 913ce06..a4e2681 100644
--- a/src/app/App.tsx
+++ b/src/app/App.tsx
@@ -1,9 +1,12 @@
import { AppShell } from "../components/layout/AppShell";
+import { DashboardPage } from "../features/dashboard/pages/DashboardPage";
function App() {
return (
-
+ {({ theme}) => (
+
+ )}
);
}
diff --git a/src/components/cards/MetricCard.tsx b/src/components/cards/MetricCard.tsx
new file mode 100644
index 0000000..d81d8d5
--- /dev/null
+++ b/src/components/cards/MetricCard.tsx
@@ -0,0 +1,108 @@
+import type { LucideIcon } from "lucide-react";
+
+type MetricCardProps = {
+ title: string;
+ value: string | number;
+ unit?: string;
+ icon: LucideIcon;
+ theme: "dark" | "light";
+ accent?: "blue" | "green" | "yellow" | "cyan" | "red";
+};
+
+const accentClasses = {
+ blue: {
+ icon: "text-sky-400",
+ bg: "bg-sky-500/10",
+ glow: "from-sky-500/20",
+ },
+ green: {
+ icon: "text-emerald-400",
+ bg: "bg-emerald-500/10",
+ glow: "from-emerald-500/20",
+ },
+ yellow: {
+ icon: "text-yellow-400",
+ bg: "bg-yellow-500/10",
+ glow: "from-yellow-500/20",
+ },
+ cyan: {
+ icon: "text-cyan-400",
+ bg: "bg-cyan-500/10",
+ glow: "from-cyan-500/20",
+ },
+ red: {
+ icon: "text-red-400",
+ bg: "bg-red-500/10",
+ glow: "from-red-500/20",
+ },
+};
+
+export function MetricCard({
+ title,
+ value,
+ unit,
+ icon: Icon,
+ theme,
+ accent = "blue",
+}: MetricCardProps) {
+ const isDark = theme === "dark";
+ const accentClass = accentClasses[accent];
+
+ return (
+
-
-
+ const toggleTheme = () => {
+ setTheme((current) => (current === "dark" ? "light" : "dark"));
+ };
-
-
+ const isDark = theme === "dark";
-
- {children}
-
-
-
-
-
+ return (
+
+
+
- );
+
+
+
+
+
+ {children({
+ theme,
+ snapshots: telemetry.snapshots,
+ })}
+
+
+
+
+
+
+ );
}
\ No newline at end of file
diff --git a/src/features/climate/components/DashboardClimateSection.tsx b/src/features/climate/components/DashboardClimateSection.tsx
new file mode 100644
index 0000000..4efd6d3
--- /dev/null
+++ b/src/features/climate/components/DashboardClimateSection.tsx
@@ -0,0 +1,105 @@
+import { Gauge, Thermometer, Waves } from "lucide-react";
+import type { DashboardOverview } from "../../dashboard/types/DashboardOverview";
+import { StatusPill } from "../../dashboard/components/StatusPill";
+
+type Props = {
+ zones: DashboardOverview["climate"]["zones"];
+};
+
+function formatValue(value: number | null, unit: string, decimals = 1) {
+ if (value === null) return "--";
+ return `${value.toFixed(decimals)} ${unit}`;
+}
+
+export function DashboardClimateSection({ zones }: Props) {
+ return (
+
+
+
+ Clima por Zona
+
+
+ Temperatura, humidade, CO₂ e estados principais dos equipamentos.
+
+
+
+
+ {zones.map((zone) => (
+
+
+
+ Zona {zone.zoneNumber}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+type MiniValueProps = {
+ icon: React.ElementType;
+ label: string;
+ value: string;
+};
+
+function MiniValue({ icon: Icon, label, value }: MiniValueProps) {
+ return (
+
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+type OpeningBarProps = {
+ label: string;
+ value: number | null;
+};
+
+function OpeningBar({ label, value }: OpeningBarProps) {
+ const safeValue = value ?? 0;
+
+ return (
+
+
+ {label}
+ {value === null ? "--" : `${value.toFixed(0)}%`}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/features/dashboard/components/DashboardMeteoSection.tsx b/src/features/dashboard/components/DashboardMeteoSection.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/src/features/dashboard/components/DashboardOperationsSection.tsx b/src/features/dashboard/components/DashboardOperationsSection.tsx
new file mode 100644
index 0000000..71fadc8
--- /dev/null
+++ b/src/features/dashboard/components/DashboardOperationsSection.tsx
@@ -0,0 +1,60 @@
+import { Droplets, Lightbulb, Power } from "lucide-react";
+import type { DashboardOverview } from "../types/DashboardOverview";
+
+type Props = {
+ irrigation?: DashboardOverview["irrigation"];
+ lighting?: DashboardOverview["lighting"];
+};
+
+export function DashboardOperationsSection({ irrigation, lighting }: Props) {
+ return (
+
+ );
+}
+
+type OperationCardProps = {
+ title: string;
+ value: string;
+ subtitle: string;
+ icon: React.ElementType;
+};
+
+function OperationCard({ title, value, subtitle, icon: Icon }: OperationCardProps) {
+ return (
+
+
+
+
+ {value}
+
+
+
+ {subtitle}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/features/dashboard/components/DashboardTrendChart.tsx b/src/features/dashboard/components/DashboardTrendChart.tsx
new file mode 100644
index 0000000..87470f6
--- /dev/null
+++ b/src/features/dashboard/components/DashboardTrendChart.tsx
@@ -0,0 +1,180 @@
+import {
+ Area,
+ AreaChart,
+ CartesianGrid,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+import type { HistorianDashboardResponse } from "../../../types/historian";
+
+type ChartSeries = {
+ key: string;
+ label: string;
+ color: string;
+};
+
+type DashboardTrendChartProps = {
+ title: string;
+ subtitle: string;
+ data: HistorianDashboardResponse | null;
+ series: ChartSeries[];
+ theme: "dark" | "light";
+};
+
+export function DashboardTrendChart({
+ title,
+ subtitle,
+ data,
+ series,
+ theme,
+}: DashboardTrendChartProps) {
+ const isDark = theme === "dark";
+ const chartData = buildChartData(data, series);
+
+ return (
+
+
+
+
+ {title}
+
+
+ {subtitle}
+
+
+
+
+ {series.map((item) => (
+
+
+
+ {item.label}
+
+
+ ))}
+
+
+
+
+
+
+
+ {series.map((item) => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {series.map((item) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+function buildChartData(
+ data: HistorianDashboardResponse | null,
+ series: ChartSeries[],
+) {
+ if (!data) return [];
+
+ const pointsByTimestamp = new Map
>();
+
+ for (const item of series) {
+ const points = data.series[item.key] ?? [];
+
+ for (const point of points) {
+ const timestamp = point.timestamp;
+ const existing = pointsByTimestamp.get(timestamp) ?? {
+ timestamp,
+ time: formatTime(timestamp),
+ };
+
+ existing[item.label] = point.numericValue ?? 0;
+ pointsByTimestamp.set(timestamp, existing);
+ }
+ }
+
+ return Array.from(pointsByTimestamp.values()).sort((a, b) =>
+ String(a.timestamp).localeCompare(String(b.timestamp)),
+ );
+}
+
+function formatTime(timestamp: string) {
+ return new Date(timestamp).toLocaleTimeString("pt-PT", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function gradientId(label: string) {
+ return `gradient-${label
+ .toLowerCase()
+ .replace(/\s+/g, "-")
+ .replace(/\./g, "")}`;
+}
\ No newline at end of file
diff --git a/src/features/dashboard/components/StatusPill.tsx b/src/features/dashboard/components/StatusPill.tsx
new file mode 100644
index 0000000..cafeaf0
--- /dev/null
+++ b/src/features/dashboard/components/StatusPill.tsx
@@ -0,0 +1,31 @@
+type StatusPillProps = {
+ active: boolean | null | undefined;
+ activeLabel?: string;
+ inactiveLabel?: string;
+};
+
+export function StatusPill({
+ active,
+ activeLabel = "Ativo",
+ inactiveLabel = "Inativo",
+}: StatusPillProps) {
+ if (active === null || active === undefined) {
+ return (
+
+ --
+
+ );
+ }
+
+ return (
+
+ {active ? activeLabel : inactiveLabel}
+
+ );
+}
\ No newline at end of file
diff --git a/src/features/dashboard/hooks/useDashboardOverviewStream.ts b/src/features/dashboard/hooks/useDashboardOverviewStream.ts
new file mode 100644
index 0000000..e9f620e
--- /dev/null
+++ b/src/features/dashboard/hooks/useDashboardOverviewStream.ts
@@ -0,0 +1,46 @@
+import { useEffect, useState } from "react";
+import { Client } from "@stomp/stompjs";
+import type { DashboardOverview } from "../types/DashboardOverview";
+
+export function useDashboardOverviewStream() {
+ const [overview, setOverview] = useState(null);
+ const [connected, setConnected] = useState(false);
+
+ useEffect(() => {
+ const client = new Client({
+ brokerURL: "ws://localhost:18450/ws",
+ reconnectDelay: 3000,
+
+ onConnect: () => {
+ setConnected(true);
+
+ client.subscribe("/topic/dashboard/overview", (frame) => {
+ const payload = JSON.parse(frame.body) as DashboardOverview;
+
+ console.log("dashboard overview", payload);
+
+ setOverview(payload);
+ });
+ },
+
+ onWebSocketClose: () => {
+ setConnected(false);
+ },
+
+ onStompError: () => {
+ setConnected(false);
+ },
+ });
+
+ client.activate();
+
+ return () => {
+ client.deactivate();
+ };
+ }, []);
+
+ return {
+ connected,
+ overview,
+ };
+}
\ No newline at end of file
diff --git a/src/features/dashboard/hooks/useHistorianDashboard.ts b/src/features/dashboard/hooks/useHistorianDashboard.ts
new file mode 100644
index 0000000..f574c10
--- /dev/null
+++ b/src/features/dashboard/hooks/useHistorianDashboard.ts
@@ -0,0 +1,60 @@
+import { useEffect, useState } from "react";
+import { fetchHistorianDashboard } from "../../../lib/api/historianApi";
+import type { HistorianDashboardResponse } from "../../../types/historian";
+
+type UseHistorianDashboardOptions = {
+ keys: string[];
+ minutesBack?: number;
+ refreshIntervalMs?: number;
+};
+
+export function useHistorianDashboard({
+ keys,
+ minutesBack = 30,
+ refreshIntervalMs = 15000,
+}: UseHistorianDashboardOptions) {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function load() {
+ try {
+ const to = new Date();
+ const from = new Date(to.getTime() - minutesBack * 60 * 1000);
+
+ const result = await fetchHistorianDashboard(
+ keys,
+ from.toISOString(),
+ to.toISOString(),
+ );
+
+ if (!cancelled) {
+ setData(result);
+ setError(null);
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setError(err instanceof Error ? err : new Error("Historian error"));
+ }
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ }
+
+ load();
+
+ const interval = window.setInterval(load, refreshIntervalMs);
+
+ return () => {
+ cancelled = true;
+ window.clearInterval(interval);
+ };
+ }, [keys.join("|"), minutesBack, refreshIntervalMs]);
+
+ return { data, loading, error };
+}
\ No newline at end of file
diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx
new file mode 100644
index 0000000..a12d438
--- /dev/null
+++ b/src/features/dashboard/pages/DashboardPage.tsx
@@ -0,0 +1,512 @@
+import {
+ Activity,
+ CloudRain,
+ Droplets,
+ Fan,
+ Lightbulb,
+ Sun,
+ Thermometer,
+ Wind,
+ Zap,
+} from "lucide-react";
+import { MetricCard } from "../../../components/cards/MetricCard";
+import { DashboardTrendChart } from "../components/DashboardTrendChart";
+import { useDashboardOverviewStream } from "../hooks/useDashboardOverviewStream";
+import { useHistorianDashboard } from "../hooks/useHistorianDashboard";
+
+type DashboardPageProps = {
+ theme: "dark" | "light";
+};
+
+const historianKeys = [
+ "meteo.exterior_temperature",
+ "meteo.exterior_humidity",
+ "meteo.radiation",
+ "climate.zone_1.temperature",
+ "climate.zone_1.humidity",
+];
+
+function formatNumber(value: number | null | undefined, decimals = 1) {
+ if (value === null || value === undefined) return "--";
+ return value.toFixed(decimals);
+}
+
+function formatBoolean(value: boolean | null | undefined) {
+ if (value === null || value === undefined) return "--";
+ return value ? "Sim" : "Não";
+}
+
+export function DashboardPage({ theme }: DashboardPageProps) {
+ const { overview, connected } = useDashboardOverviewStream();
+
+ const { data: historianData } = useHistorianDashboard({
+ keys: historianKeys,
+ minutesBack: 30,
+ refreshIntervalMs: 15000,
+ });
+
+ const meteo = overview?.meteo;
+ const zones = overview?.climate.zones ?? [];
+ const zoneOne = zones[0];
+
+ const isDark = theme === "dark";
+
+ const onlineZoneCount = zones.filter(
+ (zone) => zone.temperature !== null || zone.humidity !== null,
+ ).length;
+
+ const validZoneTemperatures = zones
+ .map((zone) => zone.temperature)
+ .filter((value): value is number => value !== null);
+
+ const averageZoneTemperature =
+ validZoneTemperatures.length > 0
+ ? validZoneTemperatures.reduce((sum, value) => sum + value, 0) /
+ validZoneTemperatures.length
+ : null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ Estado Operacional
+
+
+ Resumo rápido da instalação.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Próximos Desenvolvimentos
+
+
+ Espaço preparado para eventos, alarmes, programas e depósitos.
+
+
+
+
+
+
+
+ );
+}
+
+type DashboardModuleCardProps = {
+ theme: "dark" | "light";
+ title: string;
+ icon: React.ElementType;
+ main: string;
+ label: string;
+ accent: "blue" | "green" | "yellow" | "cyan";
+ items: [string, string][];
+};
+
+const moduleAccentClasses = {
+ blue: {
+ icon: "text-sky-400",
+ bg: "bg-sky-500/10",
+ },
+ green: {
+ icon: "text-emerald-400",
+ bg: "bg-emerald-500/10",
+ },
+ yellow: {
+ icon: "text-yellow-400",
+ bg: "bg-yellow-500/10",
+ },
+ cyan: {
+ icon: "text-cyan-400",
+ bg: "bg-cyan-500/10",
+ },
+};
+
+function DashboardModuleCard({
+ theme,
+ title,
+ icon: Icon,
+ main,
+ label,
+ accent,
+ items,
+}: DashboardModuleCardProps) {
+ const isDark = theme === "dark";
+ const accentClass = moduleAccentClasses[accent];
+
+ return (
+
+
+
+
+ {main}
+
+
+
+ {label}
+
+
+
+ {items.map(([key, value]) => (
+
+
+ {key}
+
+
+ {value}
+
+
+ ))}
+
+
+ );
+}
+
+type StatusRowProps = {
+ theme: "dark" | "light";
+ label: string;
+ value: string;
+ good: boolean;
+};
+
+function StatusRow({ theme, label, value, good }: StatusRowProps) {
+ const isDark = theme === "dark";
+
+ return (
+
+
+ {label}
+
+
+
+ {value}
+
+
+ );
+}
+
+type PlaceholderPanelProps = {
+ theme: "dark" | "light";
+ title: string;
+ text: string;
+};
+
+function PlaceholderPanel({ theme, title, text }: PlaceholderPanelProps) {
+ const isDark = theme === "dark";
+
+ return (
+
+
+ {title}
+
+
+ {text}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/features/dashboard/types/DashboardOverview.ts b/src/features/dashboard/types/DashboardOverview.ts
new file mode 100644
index 0000000..1e7fdb9
--- /dev/null
+++ b/src/features/dashboard/types/DashboardOverview.ts
@@ -0,0 +1,44 @@
+export type DashboardOverview = {
+ timestamp: string;
+
+ meteo: {
+ exteriorTemperature: number | null;
+ exteriorHumidity: number | null;
+ radiation: number | null;
+ windSpeed: number | null;
+ windDirection: number | null;
+ raining: boolean | null;
+ };
+
+ climate: {
+ zoneCount: number;
+
+ zones: {
+ zoneNumber: number;
+
+ temperature: number | null;
+ humidity: number | null;
+ co2: number | null;
+
+ fansOn: boolean | null;
+ extractorsOn: boolean | null;
+
+ zenitalLeftPercent: number | null;
+ zenitalRightPercent: number | null;
+
+ lateralLeftPercent: number | null;
+ lateralRightPercent: number | null;
+ }[];
+ };
+
+ irrigation: {
+ controllerCount: number;
+ activeValveCount: number;
+ activePumpCount: number;
+ };
+
+ lighting: {
+ sectorCount: number;
+ activeSectorCount: number;
+ };
+};
\ No newline at end of file
diff --git a/src/features/telemetry/utils/telemetryLookup.ts b/src/features/telemetry/utils/telemetryLookup.ts
new file mode 100644
index 0000000..dd8c911
--- /dev/null
+++ b/src/features/telemetry/utils/telemetryLookup.ts
@@ -0,0 +1,29 @@
+import type { TelemetrySnapshot } from "../../../types/telemetry";
+
+export function findTelemetryByName(
+ snapshots: TelemetrySnapshot[],
+ name: string,
+): TelemetrySnapshot | null {
+ return snapshots.find((snapshot) => snapshot.name === name) ?? null;
+}
+
+export function formatTelemetryValue(
+ snapshot: TelemetrySnapshot | null,
+ fallback = "--",
+): string {
+ if (!snapshot || snapshot.value === null || snapshot.value === undefined) {
+ return fallback;
+ }
+
+ if (typeof snapshot.value === "boolean") {
+ return snapshot.value ? "Sim" : "Não";
+ }
+
+ if (typeof snapshot.value === "number") {
+ return Number.isInteger(snapshot.value)
+ ? snapshot.value.toString()
+ : snapshot.value.toFixed(1);
+ }
+
+ return snapshot.value;
+}
\ No newline at end of file
diff --git a/src/index.css b/src/index.css
index c777870..3dd2255 100644
--- a/src/index.css
+++ b/src/index.css
@@ -10,4 +10,29 @@ body,
body {
font-family: Inter, system-ui, sans-serif;
+}
+
+.custom-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(56, 189, 248, 0.45) rgba(15, 23, 42, 0.35);
+}
+
+.custom-scrollbar::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-track {
+ background: rgba(15, 23, 42, 0.35);
+ border-radius: 999px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb {
+ background: linear-gradient(180deg, rgba(56, 189, 248, 0.7), rgba(14, 165, 233, 0.35));
+ border: 2px solid rgba(15, 23, 42, 0.9);
+ border-radius: 999px;
+}
+
+.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: linear-gradient(180deg, rgba(56, 189, 248, 0.95), rgba(14, 165, 233, 0.55));
}
\ No newline at end of file
diff --git a/src/lib/api/historianApi.ts b/src/lib/api/historianApi.ts
new file mode 100644
index 0000000..111f041
--- /dev/null
+++ b/src/lib/api/historianApi.ts
@@ -0,0 +1,25 @@
+import type { HistorianDashboardResponse } from "../../types/historian";
+
+const API_BASE_URL = "http://localhost:18450";
+
+export async function fetchHistorianDashboard(
+ keys: string[],
+ from: string,
+ to: string,
+): Promise {
+ const params = new URLSearchParams();
+
+ keys.forEach((key) => params.append("keys", key));
+ params.set("from", from);
+ params.set("to", to);
+
+ const response = await fetch(
+ `${API_BASE_URL}/api/historian/dashboard?${params.toString()}`,
+ );
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch historian dashboard: ${response.status}`);
+ }
+
+ return response.json();
+}
\ No newline at end of file
diff --git a/src/types/historian.ts b/src/types/historian.ts
new file mode 100644
index 0000000..f52b32f
--- /dev/null
+++ b/src/types/historian.ts
@@ -0,0 +1,12 @@
+export type HistorianSeriesPoint = {
+ timestamp: string;
+ numericValue: number | null;
+ booleanValue: boolean | null;
+ textValue: string | null;
+};
+
+export type HistorianDashboardResponse = {
+ from: string;
+ to: string;
+ series: Record;
+};
\ No newline at end of file