Refactor forecast and historian-driven meteo dashboard

This commit is contained in:
litoral05
2026-05-29 11:41:11 +01:00
parent 289a54f455
commit 3905e2adfe
24 changed files with 2494 additions and 707 deletions
@@ -262,8 +262,10 @@ export function ChartWindowPage({ theme }: ChartWindowPageProps) {
>
<header className={titleBarClass}>
<div
data-tauri-drag-region
className="flex h-full flex-1 items-center px-3 text-xs font-black"
onPointerDown={() => {
void currentWindow.startDragging();
}}
>
{chart.title}
</div>
@@ -366,7 +366,7 @@ export function ClimateChartsPage({ theme }: ClimateChartsPageProps) {
};
const closeDetachedChartWindow = async (chartId: string) => {
const label = `chart-${chartId}`;
const label = `climatecharts-${chartId}`;
const existing = await WebviewWindow.getByLabel(label);
if (!existing) return;
@@ -365,7 +365,7 @@ export function MainChartsPage({ theme }: MainChartsPageProps) {
};
const closeDetachedChartWindow = async (chartId: string) => {
const label = `chart-${chartId}`;
const label = `maincharts-${chartId}`;
const existing = await WebviewWindow.getByLabel(label);
if (!existing) return;
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import type { ModuleSensorResponse } from "../../../types/meteo";
import type { HistorianPoint } from "../components/MeteoHistoryModal";
const BACKEND_URL = "http://localhost:18450";
type SensorHistoryMap = Record<string, HistorianPoint[]>;
export function useMeteoMultiHistory(
sensors: Array<ModuleSensorResponse | null>,
hours = 6,
) {
const [pointsByKey, setPointsByKey] = useState<SensorHistoryMap>({});
const [loading, setLoading] = useState(false);
const sensorKeys = sensors
.filter((sensor): sensor is ModuleSensorResponse => Boolean(sensor))
.map((sensor) => sensor.key);
useEffect(() => {
if (!sensorKeys.length) {
setPointsByKey({});
return;
}
const controller = new AbortController();
async function loadHistory() {
try {
setLoading(true);
const to = new Date();
const from = new Date(to.getTime() - hours * 60 * 60 * 1000);
const entries = await Promise.all(
sensorKeys.map(async (key) => {
const params = new URLSearchParams({
key,
from: from.toISOString(),
to: to.toISOString(),
});
const response = await fetch(
`${BACKEND_URL}/api/historian/series?${params.toString()}`,
{ signal: controller.signal },
);
if (!response.ok) {
throw new Error(`Failed to load history for ${key}`);
}
const payload = (await response.json()) as HistorianPoint[];
return [key, payload] as const;
}),
);
setPointsByKey(Object.fromEntries(entries));
} catch (error) {
if (controller.signal.aborted) return;
console.error("Failed to load meteo histories", error);
setPointsByKey({});
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
loadHistory();
return () => controller.abort();
}, [sensorKeys.join("|"), hours]);
return {
pointsByKey,
loading,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,206 @@
import { useMemo } from "react";
import type { LucideIcon } from "lucide-react";
import {
Droplet,
Gauge,
Home,
Layers3,
Lightbulb,
Thermometer,
Wind,
} from "lucide-react";
import type { ModuleSensorResponse } from "../../../types/meteo";
import { useClimateModuleStream } from "../../climate/hooks/useClimateModuleStream";
export type SynopticVariableGroup = "Clima" | "Rega";
export type SynopticVariable = {
id: string;
sensorId: string;
key: string;
group: SynopticVariableGroup;
label: string;
value: string | number | boolean | null;
unit: string;
timestamp?: string;
connected: boolean;
icon: LucideIcon;
color: string;
};
export function useSynopticVariables() {
const climate = useClimateModuleStream();
const variables = useMemo<SynopticVariable[]>(
() =>
toSynopticVariables({
sensors: climate.sensors,
group: "Clima",
connected: climate.connected,
fallbackTimestamp: climate.lastTimestamp,
}),
[
climate.sensors,
climate.connected,
climate.lastTimestamp,
],
);
return {
variables,
groups: groupVariables(variables),
connected: climate.connected,
climateConnected: climate.connected,
loading: !climate.module,
total: variables.length,
};
}
function toSynopticVariables({
sensors,
group,
connected,
fallbackTimestamp,
}: {
sensors: ModuleSensorResponse[];
group: SynopticVariableGroup;
connected: boolean;
fallbackTimestamp: string | null;
}): SynopticVariable[] {
return sensors.map((sensor) => {
const visual = getVariableVisual(sensor, group);
return {
id: `${group}:${sensor.sensorId ?? sensor.key}`,
sensorId: String(sensor.sensorId ?? sensor.key),
key: sensor.key,
group,
label: sensor.name || sensor.key,
value: normalizeValue(sensor.value),
unit: sensor.unit ?? "",
timestamp: sensor.timestamp ?? fallbackTimestamp ?? undefined,
connected,
icon: visual.icon,
color: visual.color,
};
});
}
function groupVariables(variables: SynopticVariable[]) {
return variables.reduce<Record<SynopticVariableGroup, SynopticVariable[]>>(
(acc, variable) => {
acc[variable.group].push(variable);
return acc;
},
{
Clima: [],
Rega: [],
},
);
}
function normalizeValue(value: unknown): string | number | boolean | null {
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
value === null
) {
return value;
}
return null;
}
function getVariableVisual(
sensor: ModuleSensorResponse,
group: SynopticVariableGroup,
): {
icon: LucideIcon;
color: string;
} {
const text = normalizeText(`${sensor.key} ${sensor.name} ${sensor.unit ?? ""}`);
if (
text.includes("temperatura") ||
text.includes("temperature")
) {
return {
icon: Thermometer,
color: "#4FD1C5",
};
}
if (
text.includes("humidade") ||
text.includes("humidity")
) {
return {
icon: Droplet,
color: "#38BDF8",
};
}
if (
text.includes("vento") ||
text.includes("wind")
) {
return {
icon: Wind,
color: "#22D3EE",
};
}
if (
text.includes("radiacao") ||
text.includes("radiação") ||
text.includes("radiation")
) {
return {
icon: Lightbulb,
color: "#FACC15",
};
}
if (
text.includes("pressao") ||
text.includes("pressão") ||
text.includes("pressure")
) {
return {
icon: Gauge,
color: "#38BDF8",
};
}
if (
text.includes("nivel") ||
text.includes("nível") ||
text.includes("level")
) {
return {
icon: Layers3,
color: "#0EA5E9",
};
}
if (group === "Clima") {
return {
icon: Home,
color: "#22C55E",
};
}
return {
icon: Gauge,
color: "#A3E635",
};
}
function normalizeText(value: string): string {
return value
.toLowerCase()
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "");
}
File diff suppressed because it is too large Load Diff