-
Volume de Dados
-
{stats.count.toLocaleString("pt-PT")}
-
pontos
+
+ Volume de Dados
+
+
+ {stats.count.toLocaleString("pt-PT")}
+
+
+ pontos
+
-
+
@@ -495,8 +552,34 @@ export function MeteoHistoryModal({
);
}
+function chartPalette(isDark: boolean) {
+ return isDark
+ ? {
+ line: "#cbd5e1",
+ grid: "rgba(148,163,184,0.16)",
+ axis: "#64748b",
+ axisLine: "rgba(148,163,184,0.18)",
+ cursor: "#64748b",
+ reference: "#94a3b8",
+ compare: "#64748b",
+ }
+ : {
+ line: "#475569",
+ grid: "#e2e8f0",
+ axis: "#64748b",
+ axisLine: "#cbd5e1",
+ cursor: "#94a3b8",
+ reference: "#64748b",
+ compare: "#94a3b8",
+ };
+}
+
function EmptyState({ children }: { children: string }) {
- return
{children}
;
+ return (
+
+ {children}
+
+ );
}
function IconButton({
@@ -522,7 +605,6 @@ function MetricCard({
title,
value,
sub,
- positive,
}: {
theme: "dark" | "light";
title: string;
@@ -534,36 +616,41 @@ function MetricCard({
return (
-
{title}
-
{value}
-
{sub}
+
{title}
+
{value}
+
{sub}
);
}
function buttonClass(isDark: boolean) {
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";
+ ? `${RADIUS} border border-white/10 bg-white/[0.03] px-3 py-2 text-xs font-semibold text-slate-300 transition hover:bg-white/[0.06] hover:text-slate-100`
+ : `${RADIUS} border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-600 transition hover:bg-slate-100 hover:text-slate-950`;
+}
+
+function activeButtonClass(isDark: boolean) {
+ return isDark
+ ? `${RADIUS} border border-white/10 bg-slate-200 px-3 py-2 text-xs font-black text-slate-950`
+ : `${RADIUS} border border-slate-300 bg-slate-900 px-3 py-2 text-xs font-black text-white`;
}
function iconButtonClass(isDark: boolean) {
return isDark
- ? "rounded-lg border border-slate-700 p-2 text-slate-300 hover:bg-slate-800"
- : "rounded-lg border border-slate-200 p-2 text-slate-600 hover:bg-slate-100";
+ ? `${RADIUS} border border-white/10 bg-white/[0.03] p-2 text-slate-400 transition hover:bg-white/[0.06] hover:text-slate-100`
+ : `${RADIUS} border border-slate-200 bg-white p-2 text-slate-500 transition hover:bg-slate-100 hover:text-slate-950`;
}
function toggleClass(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";
- }
-
+ if (active) return activeButtonClass(isDark);
return buttonClass(isDark);
}
function toggleIconClass(isDark: boolean, active: boolean) {
if (active) {
- return "rounded-lg border border-cyan-400/40 bg-cyan-400/10 p-2 text-cyan-300";
+ return isDark
+ ? `${RADIUS} border border-white/10 bg-slate-200 p-2 text-slate-950`
+ : `${RADIUS} border border-slate-300 bg-slate-900 p-2 text-white`;
}
return iconButtonClass(isDark);
@@ -571,8 +658,8 @@ function toggleIconClass(isDark: boolean, active: boolean) {
function cardClass(isDark: boolean) {
return isDark
- ? "rounded-xl border border-slate-700/60 bg-[#0a1728] p-3"
- : "rounded-xl border border-slate-200 bg-slate-50 p-3";
+ ? `${RADIUS} border border-white/10 bg-[#111827] p-3`
+ : `${RADIUS} border border-slate-200 bg-white p-3`;
}
function formatValue(value: number | null, unit: string) {
diff --git a/src/features/meteo/components/WeatherForecastCard.tsx b/src/features/meteo/components/WeatherForecastCard.tsx
new file mode 100644
index 0000000..23b38db
--- /dev/null
+++ b/src/features/meteo/components/WeatherForecastCard.tsx
@@ -0,0 +1,551 @@
+import {
+ CloudRain,
+ Droplets,
+ MapPin,
+ Navigation,
+ Sun,
+ Thermometer,
+ Wind,
+ ArrowRight,
+ Cloud,
+ type LucideIcon,
+} from "lucide-react";
+import type { WeatherForecastResponse } from "../../../types/weather";
+
+type Props = {
+ theme: "dark" | "light";
+ forecast: WeatherForecastResponse | null;
+ loading: boolean;
+ error: string | null;
+ compact?: boolean;
+ onOpenMeteo?: () => void;
+};
+
+const RADIUS = "rounded-[5px]";
+
+export function WeatherForecastCard({
+ theme,
+ forecast,
+ loading,
+ error,
+ compact = false,
+ onOpenMeteo,
+}: Props) {
+ const isDark = theme === "dark";
+
+ if (compact) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ Previsão meteorológica
+
+
+
+ {forecast
+ ? `${forecast.location.name}, ${forecast.location.country}`
+ : "Meteorologia externa"}
+
+
+
+
+ Dados externos de previsão
+
+
+
+ {forecast?.current.condition?.icon && (
+

+ )}
+
+
+ {loading ? (
+ A carregar previsão meteorológica...
+ ) : error ? (
+ {error}
+ ) : !forecast ? (
+ Sem previsão meteorológica disponível.
+ ) : (
+
+
+
+
+ {forecast.daily.slice(1, 7).map((day) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function TodayForecastHero({
+ theme,
+ forecast,
+}: {
+ theme: "dark" | "light";
+ forecast: WeatherForecastResponse;
+}) {
+ const isDark = theme === "dark";
+
+ const today = forecast.daily[0];
+
+ return (
+
+
+
+ {today.condition?.icon && (
+

+ )}
+
+
+
+ Hoje
+
+
+
+ {formatDay(today.date)}
+
+
+
+ {Math.round(today.maxTemperatureC)}°
+
+ / {Math.round(today.minTemperatureC)}°
+
+
+
+
+ {today.condition?.text ?? "--"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function DailyForecastTile({
+ theme,
+ day,
+}: {
+ theme: "dark" | "light";
+ day: WeatherForecastResponse["daily"][number];
+}) {
+ const isDark = theme === "dark";
+
+ return (
+
+
+
+
+ {formatDay(day.date)}
+
+
+
+ {day.condition?.text ?? "--"}
+
+
+
+ {day.condition?.icon && (
+

+ )}
+
+
+
+
+ {Math.round(day.maxTemperatureC)}°
+
+ / {Math.round(day.minTemperatureC)}°
+
+
+
+
+
+
+
+ {day.dailyRainChance}%
+
+
+
+
+ UV {day.uv.toFixed(0)}
+
+
+
+ );
+}
+
+function WeatherMiniStat({
+ theme,
+ icon: Icon,
+ label,
+ value,
+}: {
+ theme: "dark" | "light";
+ icon: LucideIcon;
+ label: string;
+ value: string;
+}) {
+ const isDark = theme === "dark";
+
+ return (
+
+
+
+
{label}
+
+
+ {value}
+
+
+ );
+}
+
+function StateMessage({
+ theme,
+ children,
+}: {
+ theme: "dark" | "light";
+ children: string;
+}) {
+ const isDark = theme === "dark";
+
+ return (
+
+ {children}
+
+ );
+}
+
+function formatDay(date: string) {
+ return new Date(date).toLocaleDateString("pt-PT", {
+ weekday: "short",
+ day: "2-digit",
+ });
+}
+
+const eyebrowDark =
+ "text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500";
+const eyebrowLight =
+ "text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400";
+
+const titleDark = "mt-2 text-xl font-black tracking-[-0.03em] text-slate-100";
+const titleLight = "mt-2 text-xl font-black tracking-[-0.03em] text-slate-950";
+
+const subtitleDark = "mt-1 flex items-center gap-2 text-sm text-slate-400";
+const subtitleLight = "mt-1 flex items-center gap-2 text-sm text-slate-500";
+
+function CompactWeatherCard({
+ theme,
+ forecast,
+ loading,
+ error,
+ onOpenMeteo,
+}: {
+ theme: "dark" | "light";
+ forecast: WeatherForecastResponse | null;
+ loading: boolean;
+ error: string | null;
+ onOpenMeteo?: () => void;
+}) {
+ const isDark = theme === "dark";
+
+ const today = forecast?.daily?.[0];
+
+ return (
+
+
+
+
+ {forecast
+ ? `${forecast.location.name}, ${forecast.location.country}`
+ : "Meteorologia"}
+
+
+ {loading ? (
+
+ A carregar previsão...
+
+ ) : error ? (
+
+ {error}
+
+ ) : today ? (
+ <>
+
+
+
+ Hoje
+
+
+
+ {formatDay(today.date)}
+
+
+
+
+ {Math.round(today.maxTemperatureC)}°
+
+
+
+ / {Math.round(today.minTemperatureC)}°
+
+
+
+
+ {today.condition?.text ?? "--"}
+
+
+
+
+ Chuva {today.dailyRainChance}%
+
+
+
+ UV {today.uv.toFixed(0)}
+
+
+
+
+ {today.condition?.icon ? (
+

+ ) : (
+
+ )}
+
+
+
+
+
+ >
+ ) : (
+
+ Sem previsão diária disponível.
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/features/meteo/hooks/useWeatherForecast.ts b/src/features/meteo/hooks/useWeatherForecast.ts
new file mode 100644
index 0000000..00a8dd5
--- /dev/null
+++ b/src/features/meteo/hooks/useWeatherForecast.ts
@@ -0,0 +1,99 @@
+import { useEffect, useState } from "react";
+import type { WeatherForecastResponse } from "../../../types/weather";
+
+const BACKEND_URL = "http://localhost:18450";
+
+type LocationState = {
+ latitude: number;
+ longitude: number;
+};
+
+export function useWeatherForecast() {
+ const [forecast, setForecast] = useState
(null);
+ const [location, setLocation] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!navigator.geolocation) {
+ setError("Geolocalização não suportada.");
+ setLoading(false);
+ return;
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ setLocation({
+ latitude: position.coords.latitude,
+ longitude: position.coords.longitude,
+ });
+ },
+ () => {
+ // Fallback Leiria
+ setLocation({
+ latitude: 39.75,
+ longitude: -8.8,
+ });
+ },
+ {
+ enableHighAccuracy: false,
+ timeout: 8000,
+ maximumAge: 1000 * 60 * 60 * 12,
+ },
+ );
+ }, []);
+
+ useEffect(() => {
+ if (!location) return;
+
+ const latitude = location.latitude;
+ const longitude = location.longitude;
+
+ const controller = new AbortController();
+
+ async function loadForecast() {
+ try {
+ setLoading(true);
+ setError(null);
+
+ const params = new URLSearchParams({
+ lat: String(latitude),
+ lon: String(longitude),
+ days: "7",
+ });
+
+ const response = await fetch(
+ `${BACKEND_URL}/api/weather/forecast?${params.toString()}`,
+ { signal: controller.signal },
+ );
+
+ if (!response.ok) {
+ throw new Error("Failed to load weather forecast");
+ }
+
+ const payload = (await response.json()) as WeatherForecastResponse;
+ setForecast(payload);
+ } catch (error) {
+ if (controller.signal.aborted) return;
+
+ console.error("Failed to load weather forecast", error);
+ setError("Não foi possível carregar a previsão meteorológica.");
+ setForecast(null);
+ } finally {
+ if (!controller.signal.aborted) {
+ setLoading(false);
+ }
+ }
+ }
+
+ loadForecast();
+
+ return () => controller.abort();
+ }, [location]);
+
+ return {
+ forecast,
+ loading,
+ error,
+ };
+}
\ No newline at end of file
diff --git a/src/features/meteo/pages/MeteoPage.tsx b/src/features/meteo/pages/MeteoPage.tsx
index caf41ec..9ae2d50 100644
--- a/src/features/meteo/pages/MeteoPage.tsx
+++ b/src/features/meteo/pages/MeteoPage.tsx
@@ -1,16 +1,15 @@
import { useEffect, useState, type ReactNode } from "react";
import {
+ Activity,
ChartNoAxesColumnIncreasing,
- Table2,
CloudRain,
Droplets,
MoreHorizontal,
- Radio,
Sun,
+ Table2,
Thermometer,
+ TrendingUp,
Wind,
- Wifi,
- ChevronRight,
} from "lucide-react";
import { useMeteoModuleStream } from "../hooks/useMeteoModuleStream";
import { MeteoHistoryModal } from "../components/MeteoHistoryModal";
@@ -21,25 +20,36 @@ import {
useAccumulatedHistory,
type AccumulatedRange,
} from "../hooks/useAccumulatedHistory";
+import { useWeatherForecast } from "../hooks/useWeatherForecast";
+import { WeatherForecastCard } from "../components/WeatherForecastCard";
type MeteoPageProps = {
theme: "dark" | "light";
};
type HistoryMap = Record;
+type Accent = "amber" | "blue" | "cyan" | "emerald";
const MAX_HISTORY_POINTS = 34;
+const RADIUS = "rounded-[5px]";
export function MeteoPage({ theme }: MeteoPageProps) {
- const { sensors, sensorCount, connected, lastTimestamp } =
- useMeteoModuleStream();
+ const { sensors } = useMeteoModuleStream();
const [selectedSensor, setSelectedSensor] =
useState(null);
const [history, setHistory] = useState({});
+ const [openMenu, setOpenMenu] = useState(null);
+
+ const [selectedAccumulated, setSelectedAccumulated] = useState<{
+ title: string;
+ sensor: ModuleSensorResponse | null;
+ } | null>(null);
+
+ const [accumulatedRange, setAccumulatedRange] =
+ useState("7d");
- const isDark = theme === "dark";
const temperature = findSensor(sensors, "temperatura.exterior");
const humidity = findSensor(sensors, "humidade.exterior");
@@ -59,26 +69,19 @@ export function MeteoPage({ theme }: MeteoPageProps) {
findSensor(sensors, "chuva.instantanea") ??
findSensor(sensors, "chuva.intensidade") ??
maxSensor(rainSensors);
+
const rainValue = numericValue(rainSensor);
const isRaining = rainValue !== null && rainValue > 0;
- const [openMenu, setOpenMenu] = useState(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,
);
+ const weatherForecast = useWeatherForecast();
+
useEffect(() => {
const samples: Array<[string, number | null]> = [
["temperatura.exterior", numericValue(temperature)],
@@ -113,180 +116,167 @@ export function MeteoPage({ theme }: MeteoPageProps) {
]);
return (
- <>
-
-
-
-
-
}
- accent="amber"
- status={temperatureBadge(temperature)}
- values={history["temperatura.exterior"]}
- menuOpen={openMenu === "temperature"}
- onMenuToggle={() => setOpenMenu(openMenu === "temperature" ? null : "temperature")}
- actions={[
- {
- label: "Ver gráfico",
- icon:
,
- onClick: () => {
- setSelectedSensor(temperature ?? null);
- setOpenMenu(null);
- },
- },
- ]}
- />
+
+
-
}
- accent="blue"
- status={humidityBadge(humidity)}
- values={history["humidade.exterior"]}
- menuOpen={openMenu === "humidity"}
- onMenuToggle={() =>
- setOpenMenu(openMenu === "humidity" ? null : "humidity")
- }
- actions={[
- {
- label: "Ver gráfico",
- icon:
,
- onClick: () => {
- setSelectedSensor(humidity ?? null);
- setOpenMenu(null);
- },
- },
- ]}
- />
+
+
+
}
+ accent="amber"
+ status={temperatureBadge(temperature)}
+ values={history["temperatura.exterior"]}
+ menuOpen={openMenu === "temperature"}
+ onMenuToggle={() =>
+ setOpenMenu(openMenu === "temperature" ? null : "temperature")
+ }
+ actions={[
+ {
+ label: "Ver gráfico",
+ icon:
,
+ onClick: () => {
+ setSelectedSensor(temperature ?? null);
+ setOpenMenu(null);
+ },
+ },
+ ]}
+ />
-
}
- accent="emerald"
- status={isRaining ? "A chover" : "Sem chuva"}
- values={history["chuva.total"]}
- menuOpen={openMenu === "rain"}
- onMenuToggle={() =>
- setOpenMenu(openMenu === "rain" ? null : "rain")
- }
- actions={[
- {
- label: "Ver gráfico",
- icon:
,
- onClick: () => {
- setSelectedSensor(rainSensor ?? null);
- setOpenMenu(null);
- },
- },
- {
- label: "Ver acumulado",
- icon:
,
- onClick: () => {
- setSelectedAccumulated({
- title: "Precipitação acumulada",
- sensor: rainSensor ?? null,
- });
+
}
+ accent="blue"
+ status={humidityBadge(humidity)}
+ values={history["humidade.exterior"]}
+ menuOpen={openMenu === "humidity"}
+ onMenuToggle={() =>
+ setOpenMenu(openMenu === "humidity" ? null : "humidity")
+ }
+ actions={[
+ {
+ label: "Ver gráfico",
+ icon:
,
+ onClick: () => {
+ setSelectedSensor(humidity ?? null);
+ setOpenMenu(null);
+ },
+ },
+ ]}
+ />
- setOpenMenu(null);
- },
- },
- ]}
- />
-
+
}
+ accent="emerald"
+ status={isRaining ? "A chover" : "Sem chuva"}
+ values={history["chuva.total"]}
+ menuOpen={openMenu === "rain"}
+ onMenuToggle={() =>
+ setOpenMenu(openMenu === "rain" ? null : "rain")
+ }
+ actions={[
+ {
+ label: "Ver gráfico",
+ icon:
,
+ onClick: () => {
+ setSelectedSensor(rainSensor ?? null);
+ setOpenMenu(null);
+ },
+ },
+ {
+ label: "Ver acumulado",
+ icon:
,
+ onClick: () => {
+ setSelectedAccumulated({
+ title: "Precipitação acumulada",
+ sensor: rainSensor ?? null,
+ });
+ setOpenMenu(null);
+ },
+ },
+ ]}
+ />
+
-
+
-
-
}
- accent="cyan"
- status={windBadge(windSpeed)}
- values={history["vento.velocidade"]}
- menuOpen={openMenu === "wind"}
- onMenuToggle={() =>
- setOpenMenu(openMenu === "wind" ? null : "wind")
- }
- actions={[
- {
- label: "Ver gráfico",
- icon:
,
- onClick: () => {
- setSelectedSensor(windSpeed ?? null);
- setOpenMenu(null);
- },
- },
- ]}
- />
+
+
}
+ accent="cyan"
+ status={windBadge(windSpeed)}
+ values={history["vento.velocidade"]}
+ menuOpen={openMenu === "wind"}
+ onMenuToggle={() =>
+ setOpenMenu(openMenu === "wind" ? null : "wind")
+ }
+ actions={[
+ {
+ label: "Ver gráfico",
+ icon:
,
+ onClick: () => {
+ setSelectedSensor(windSpeed ?? null);
+ setOpenMenu(null);
+ },
+ },
+ ]}
+ />
-
}
- accent="amber"
- status={radiationBadge(radiation)}
- values={history["radiacao.solar"]}
- menuOpen={openMenu === "radiation"}
- onMenuToggle={() =>
- setOpenMenu(openMenu === "radiation" ? null : "radiation")
- }
- actions={[
- {
- label: "Ver gráfico",
- icon:
,
- onClick: () => {
- setSelectedSensor(radiation ?? null);
- setOpenMenu(null);
- },
- },
- {
- label: "Ver acumulado",
- icon:
,
- onClick: () => {
- setSelectedAccumulated({
- title: "Radiação solar acumulada",
- sensor: radiation ?? null,
- });
-
- setOpenMenu(null);
- },
- },
- ]}
- />
-
-
-
-
-
+
}
+ accent="amber"
+ status={radiationBadge(radiation)}
+ values={history["radiacao.solar"]}
+ menuOpen={openMenu === "radiation"}
+ onMenuToggle={() =>
+ setOpenMenu(openMenu === "radiation" ? null : "radiation")
+ }
+ actions={[
+ {
+ label: "Ver gráfico",
+ icon:
,
+ onClick: () => {
+ setSelectedSensor(radiation ?? null);
+ setOpenMenu(null);
+ },
+ },
+ {
+ label: "Ver acumulado",
+ icon:
,
+ onClick: () => {
+ setSelectedAccumulated({
+ title: "Radiação solar acumulada",
+ sensor: radiation ?? null,
+ });
+ setOpenMenu(null);
+ },
+ },
+ ]}
+ />
+
setSelectedAccumulated(null)}
/>
- >
+
);
}
@@ -335,7 +325,7 @@ function MetricTile({
customValue?: string;
customUnit?: string;
icon: ReactNode;
- accent: "amber" | "blue" | "cyan" | "emerald";
+ accent: Accent;
status: string;
values?: number[];
menuOpen: boolean;
@@ -347,25 +337,23 @@ function MetricTile({
}>;
}) {
const isDark = theme === "dark";
- const colors = accentColors(accent);
+ const colors = accentColors(accent, isDark);
const value = customValue ?? formatValue(sensor);
const unit = customUnit ?? sensor?.unit;
+ const trend = getTrend(values);
return (
-
-
+
{icon}
@@ -374,8 +362,8 @@ function MetricTile({
{title}
@@ -392,7 +380,7 @@ function MetricTile({
-
+
@@ -412,8 +401,8 @@ function MetricTile({
{actions.map((action) => (
@@ -426,8 +415,8 @@ function MetricTile({
}}
className={
isDark
- ? "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-semibold text-slate-200 transition hover:bg-white/10 hover:text-white"
- : "flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 hover:text-slate-950"
+ ? `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-sm font-semibold text-slate-300 transition hover:bg-white/5 hover:text-white`
+ : `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 hover:text-slate-950`
}
>
{action.icon}
@@ -439,43 +428,59 @@ function MetricTile({
-
-
-
- {value}
- {unit && (
+
+
+
+
+ {value}
+ {unit && (
+
+ {unit}
+
+ )}
+
+
+
+
+ {status}
+
+
- {unit}
+
+ {trend}
- )}
+
-
-
- {status}
-
-
+
);
}
@@ -492,48 +497,75 @@ function CompassPanel({
const degrees = direction !== null ? Math.round(direction) : null;
return (
-
-
-
+
+
+
Direção do vento
-
+
-
+
{cardinal}
-
-
- Graus
-
-
- {degrees !== null ? `${degrees}°` : "--"}
-
-
-
-
-
- Quadrante
-
-
- {directionQuadrant(direction)}
-
-
+
+
-
-
-
-
-
+
+
+
+
+
+
+
{Array.from({ length: 72 }).map((_, index) => {
const major = index % 6 === 0;
@@ -541,40 +573,48 @@ function CompassPanel({
return (
);
})}
-
-
-
-
+
+
+
+
-
+
-
+
);
}
-function CompassLabel({
- label,
- className,
-}: {
- label: string;
- className: string;
-}) {
- return (
-
- {label}
-
- );
-}
-
-function directionQuadrant(direction: number | null) {
- if (direction === null) return "--";
- if (direction >= 315 || direction < 45) return "Norte";
- if (direction >= 45 && direction < 135) return "Este";
- if (direction >= 135 && direction < 225) return "Sul";
- return "Oeste";
-}
-
-function StatusTile({
+function CompassStat({
theme,
- connected,
- sensorCount,
- lastTimestamp,
+ label,
+ value,
+ highlighted,
}: {
theme: "dark" | "light";
- connected: boolean;
- sensorCount: number;
- lastTimestamp: string | null;
+ label: string;
+ value: string;
+ highlighted?: boolean;
}) {
const isDark = theme === "dark";
@@ -638,64 +660,55 @@ function StatusTile({
-
-
-
- Estado do módulo
-
-
+
+ {label}
+
-
-
-
-
-
+
+ {value}
+
);
}
-function StatusRow({
- theme,
+function CompassLabel({
label,
- value,
- online,
+ className,
+ isDark,
}: {
- theme: "dark" | "light";
label: string;
- value: string;
- online?: boolean;
+ className: string;
+ isDark: boolean;
}) {
- const isDark = theme === "dark";
-
return (
-
-
- {label}
-
-
-
- {online !== undefined && (
-
- )}
-
- {value}
-
-
-
-
+
+ {label}
+
);
}
@@ -712,8 +725,8 @@ function Sparkline({
}) {
if (!values || values.length < 2) return null;
- const width = 210;
- const height = 56;
+ const width = 220;
+ const height = 62;
const padding = 8;
const min = Math.min(...values);
const max = Math.max(...values);
@@ -728,7 +741,12 @@ function Sparkline({
const last = points[points.length - 1].split(",").map(Number);
return (
-