import { useEffect, useMemo, useState } from "react";
import {
Activity,
Cloud,
CloudRain,
Droplets,
Gauge,
Compass,
Eye,
Sun,
Zap,
Thermometer,
Wind,
X,
type LucideIcon,
} from "lucide-react";
import weatherBoardBackground from "../../../assets/meteo-pane.png";
import sunnyBackground from "../../../assets/weather/Sunny.png";
import partlyCloudyBackground from "../../../assets/weather/Partly Cloudy.png";
import cloudyBackground from "../../../assets/weather/Cloudy.png";
import rainyBackground from "../../../assets/weather/Rainy.png";
import overcastBackground from "../../../assets/weather/Overcast.png";
import foggyBackground from "../../../assets/weather/Foggy.png";
import patchyRainBackground from "../../../assets/weather/Patchy Rain.png";
import thunderstormBackground from "../../../assets/weather/Thunderstorm.png";
import snowBackground from "../../../assets/weather/Snow.png";
import snowstormBackground from "../../../assets/weather/Snowstorm.png";
import { useMeteoModuleStream } from "../hooks/useMeteoModuleStream";
import { useMeteoMultiHistory } from "../hooks/useMeteoMultiHistory";
import { useWeatherForecast } from "../hooks/useWeatherForecast";
import type { WeatherForecastResponse } from "../../../types/weather";
import {
numericSensorValue,
selectMeteoSensors,
} from "../domain/meteoSensorSelectors";
import WorkspaceChart, {
type WorkspaceChartConfig,
type WorkspaceChartInterval,
type WorkspaceChartMode,
type WorkspaceChartTimeRange,
} from "../../../components/charts/WorkspaceChart";
import type { HistorianPoint } from "../components/MeteoHistoryModal";
import { useAccumulatedHistory } from "../hooks/useAccumulatedHistory";
import type { AccumulatedBucket } from "../hooks/useAccumulatedHistory";
type MeteoPageProps = {
theme: "dark" | "light";
onOpenMeteoCharts: () => void;
};
type ChartPoint = {
timestamp: string;
value: number;
};
type ChartSeries = {
key: HistoryKey;
title: string;
shortTitle: string;
unit: string;
values: ChartPoint[];
accent: Accent;
};
type Accent = "amber" | "blue" | "cyan" | "emerald" | "violet";
const HISTORY_HOURS = 6;
const RADIUS = "rounded-[5px]";
const HISTORY_KEYS = {
temperature: "temperature",
humidity: "humidity",
windSpeed: "windSpeed",
windDirection: "windDirection",
radiation: "radiation",
} as const;
type HistoryKey = (typeof HISTORY_KEYS)[keyof typeof HISTORY_KEYS];
export function MeteoPage({ theme, onOpenMeteoCharts }: MeteoPageProps) {
const { sensors } = useMeteoModuleStream();
const weatherForecast = useWeatherForecast();
const [weatherBoardOpen, setWeatherBoardOpen] = useState(false);
const selected = selectMeteoSensors(sensors);
const temperature = selected.temperature;
const humidity = selected.humidity;
const windDirection = selected.windDirection;
const windSpeed = selected.windSpeed;
const co2 = selected.co2;
const radiation = selected.radiation;
const forecast = weatherForecast.forecast;
const { buckets: rainBuckets } = useAccumulatedHistory(selected.rain ?? null, "7d");
const { buckets: radiationBuckets } = useAccumulatedHistory(radiation ?? null, "7d");
const rainTodayBucket = todayAccumulatedBucket(rainBuckets);
const radiationTodayBucket = todayAccumulatedBucket(radiationBuckets);
const accumulatedRainToday = formatAccumulatedValue(
rainTodayBucket?.total ?? null,
rainTodayBucket?.unit ?? "mm",
);
const accumulatedRadiationToday = formatAccumulatedValue(
radiationTodayBucket?.total ?? null,
radiationTodayBucket?.unit,
);
const { pointsByKey, loading: historyLoading } = useMeteoMultiHistory(
[
temperature ?? null,
humidity ?? null,
windSpeed ?? null,
windDirection ?? null,
radiation ?? null,
],
HISTORY_HOURS,
);
const [selectedForecastDayIndex, setSelectedForecastDayIndex] = useState(0);
const forecastDays = forecast?.daily ?? [];
const selectedForecastDay =
forecastDays[selectedForecastDayIndex] ?? forecastDays[0] ?? null;
useEffect(() => {
if (!forecastDays.length) {
setSelectedForecastDayIndex(0);
return;
}
if (selectedForecastDayIndex > forecastDays.length - 1) {
setSelectedForecastDayIndex(0);
}
}, [forecastDays.length, selectedForecastDayIndex]);
// Live station readings only. Forecast values are handled by WeatherHeroPanel.
const currentTemperature = numericSensorValue(temperature);
const currentHumidity = numericSensorValue(humidity);
const currentWindSpeed = numericSensorValue(windSpeed);
const currentRadiation = numericSensorValue(radiation);
const currentWindDirection = numericSensorValue(windDirection);
const currentCo2 = numericSensorValue(co2)
const currentDewPoint = calculateDewPoint(
currentTemperature,
currentHumidity,
);
const chartSeries = useMemo(
() => [
{
key: HISTORY_KEYS.temperature,
title: "Temperatura (°C)",
shortTitle: "Temperatura",
unit: temperature?.unit ?? "°C",
values: historianSeries(pointsByKey[temperature?.key ?? ""]),
accent: "amber" as Accent,
},
{
key: HISTORY_KEYS.humidity,
title: "Humidade (%)",
shortTitle: "Humidade",
unit: humidity?.unit ?? "%",
values: historianSeries(pointsByKey[humidity?.key ?? ""]),
accent: "blue" as Accent,
},
{
key: HISTORY_KEYS.windSpeed,
title: "Vento (km/h)",
shortTitle: "Vento",
unit: windSpeed?.unit ?? "km/h",
values: historianSeries(pointsByKey[windSpeed?.key ?? ""]),
accent: "emerald" as Accent,
},
{
key: HISTORY_KEYS.radiation,
title: "Radiação (W/m²)",
shortTitle: "Radiação",
unit: radiation?.unit ?? "W/m²",
values: historianSeries(pointsByKey[radiation?.key ?? ""]),
accent: "violet" as Accent,
},
],
[
pointsByKey,
humidity?.key,
humidity?.unit,
radiation?.key,
radiation?.unit,
temperature?.key,
temperature?.unit,
windSpeed?.key,
windSpeed?.unit,
],
);
const windDirectionHistory = useMemo(
() => historianSeries(pointsByKey[windDirection?.key ?? ""]),
[pointsByKey, windDirection?.key],
);
return (
setWeatherBoardOpen(true)}
/>
{weatherBoardOpen && (
setWeatherBoardOpen(false)}
/>
)}
);
}
function WeatherBoardModal({
temperature,
humidity,
windSpeed,
windDirection,
co2,
radiation,
dewPoint,
accumulatedRain, // ← add
accumulatedRadiation, // ← add
onClose,
}: {
theme: "dark" | "light";
temperature: number | null;
humidity: number | null;
windSpeed: number | null;
co2: number | null;
windDirection: number | null;
radiation: number | null;
dewPoint: number | null;
accumulatedRain: { value: string; unit: string };
accumulatedRadiation: { value: string; unit: string };
onClose: () => void;
}) {
const angle = windDirection ?? 0;
const cardinal = directionName(windDirection);
return (
{/* Center dot */}
{/* Degree label */}
{Math.round(windDirection ?? 0)}°
);
}
function todayAccumulatedBucket(buckets: AccumulatedBucket[]) {
const now = new Date();
const todayLabel = `${String(now.getDate()).padStart(2, "0")}/${String(
now.getMonth() + 1,
).padStart(2, "0")}`;
return buckets.find((bucket) => bucket.label === todayLabel) ?? null;
}
function formatAccumulatedValue(value: number | null, unit?: string) {
if (value === null) return { value: "--", unit: "" };
if (unit === "Wh/m²") {
return {
value: value.toFixed(1),
unit: "kWh/m²",
};
}
return {
value: value.toFixed(1),
unit: unit ?? "",
};
}
function BoardText({
className,
value,
}: {
className: string;
value: string;
}) {
return (
{value}
);
}
function BoardMetric({
className,
icon: Icon,
label,
value,
unit,
color,
}: {
className: string;
icon: LucideIcon;
label: string;
value: string;
unit: string;
color: "blue" | "cyan" | "yellow" | "orange" | "white" | "muted";
}) {
const valueColor =
color === "blue"
? "text-blue-400"
: color === "cyan"
? "text-cyan-300"
: color === "yellow"
? "text-yellow-300"
: color === "orange"
? "text-orange-500"
: color === "muted"
? "text-slate-300"
: "text-white";
return (
{value}
{unit && (
({unit})
)}
);
}
function WeatherHeroPanel({
theme,
forecast,
selectedDay,
selectedDayIndex,
loading,
error,
onOpenWeatherBoard,
}: {
theme: "dark" | "light";
forecast: WeatherForecastResponse | null;
selectedDay: WeatherForecastResponse["daily"][number] | null;
selectedDayIndex: number;
loading: boolean;
error: string | null;
onOpenWeatherBoard: () => void;
}) {
const isDark = theme === "dark";
const day = selectedDay ?? forecast?.daily?.[0];
if (!day) {
return Sem previsão disponível.;
}
const location = forecast
? `${forecast.location.name}, ${forecast.location.country}`
: "Mira, Portugal";
const conditionText = day.condition?.text ?? "--";
const heroBackground = weatherBackgroundForCondition(conditionText);
const heroTemperature = day.maxTemperatureC ?? null;
const heroMinTemperature = day.minTemperatureC ?? null;
const heroDateLabel =
selectedDayIndex === 0
? "Hoje"
: selectedDayIndex === 1
? "Amanhã"
: weekday(day.date);
return (
{location}
{selectedDayIndex === 0
? "Condições de hoje"
: `Previsão para ${heroDateLabel}`}
{loading ? (
A carregar meteorologia...
) : error ? (
{error}
) : (
{day.condition?.icon ? (

) : (
)}
{formatNumber(heroTemperature, 1)}°
{conditionText}
{heroMinTemperature !== null
? `Mínima ${formatNumber(heroMinTemperature, 0)}°`
: "Sem mínima disponível"}
)}
);
}
function ForecastPanel({
theme,
forecast,
loading,
error,
selectedIndex,
onSelectDay,
}: {
theme: "dark" | "light";
forecast: WeatherForecastResponse | null;
loading: boolean;
error: string | null;
selectedIndex: number;
onSelectDay: (index: number) => void;
}) {
const isDark = theme === "dark";
const days = forecast?.daily?.slice(0, 7) ?? [];
const selectedDay = days[selectedIndex] ?? days[0] ?? null;
return (
Previsão diária
Próximos 7 dias
{selectedDay && (
Dia selecionado
{weekday(selectedDay.date)} · {shortDate(selectedDay.date)}
)}
{loading ? (
A carregar previsão...
) : error ? (
{error}
) : !days.length ? (
Sem previsão diária disponível.
) : (
{days.map((day, index) => (
onSelectDay(index)}
/>
))}
)}
);
}
function ForecastDayCard({
theme,
day,
index,
active,
onClick,
}: {
theme: "dark" | "light";
day: WeatherForecastResponse["daily"][number];
index: number;
active: boolean;
onClick: () => void;
}) {
const isDark = theme === "dark";
const label = index === 0 ? "Hoje" : index === 1 ? "Amanhã" : weekday(day.date);
const conditionText = day.condition?.text ?? "";
const background = weatherBackgroundForCondition(conditionText);
return (
);
}
function WeatherSummaryPanel({
theme,
temperature,
humidity,
windSpeed,
dewPoint,
temperatureUnit,
humidityUnit,
windUnit,
radiation,
radiationUnit
}: {
theme: "dark" | "light";
temperature: number | null;
humidity: number | null;
windSpeed: number | null;
dewPoint: number | null;
temperatureUnit: string;
humidityUnit: string;
windUnit: string;
radiation: number | null;
radiationUnit: string;
}) {
const isDark = theme === "dark";
return (
);
}
function radiationBadge(value: number | null) {
if (value === null) return "Sem dados";
if (value >= 800) return "Alta";
if (value >= 400) return "Média";
return "Baixa";
}
function WindDirectionPanel({
theme,
direction,
history,
windHistory,
}: {
theme: "dark" | "light";
direction: number | null;
history: Array<{
timestamp: string;
value: number;
}>;
windHistory: Array<{
timestamp: string;
value: number;
}>;
}) {
const isDark = theme === "dark";
const angle = direction ?? 0;
const cardinal = directionName(direction);
const degrees = direction !== null ? Math.round(direction) : null;
const previousDirection =
history.length > 0
? directionName(history[history.length - 1].value)
: "--";
const currentDirection = directionName(direction);
const directionTrend =
previousDirection === currentDirection
? currentDirection
: `${previousDirection} → ${currentDirection}`;
const maxGust =
windHistory.length > 0
? Math.max(...windHistory.map((point) => point.value))
: null;
const consistency = windConsistency(history);
return (
Direção do vento
{Array.from({ length: 48 }).map((_, index) => {
const major = index % 6 === 0;
return (
);
})}
{cardinal}
{degrees ?? "--"}°
);
}
function windConsistency(
history: Array<{ timestamp: string; value: number }>
) {
if (history.length < 2) return "--";
let totalVariation = 0;
for (let i = 1; i < history.length; i++) {
const previous = history[i - 1].value;
const current = history[i].value;
let delta = Math.abs(current - previous);
if (delta > 180) {
delta = 360 - delta;
}
totalVariation += delta;
}
const averageVariation = totalVariation / (history.length - 1);
if (averageVariation <= 5) return "Muito estável";
if (averageVariation <= 15) return "Estável";
if (averageVariation <= 35) return "Variável";
return "Instável";
}
function RealtimeChartPanel({
theme,
onOpenMeteoCharts,
series,
historyLoading,
}: {
theme: "dark" | "light";
onOpenMeteoCharts: () => void;
series: ChartSeries[];
historyLoading: boolean;
hours: number;
}) {
const [mode, setMode] = useState("line");
const [timeRange, setTimeRange] = useState("6h");
const [interval, setInterval] = useState("5m");
const [visibleKeys, setVisibleKeys] = useState([
HISTORY_KEYS.temperature,
HISTORY_KEYS.humidity,
]);
const chart: WorkspaceChartConfig = {
id: "meteo-realtime",
title: "Gráficos em tempo real",
subtitle: "Leituras históricas da estação meteorológica",
icon: Activity,
status: "online",
sourceLabel: "Estação",
mode,
timeRange,
interval,
variables: series.map((item) => ({
key: item.key,
label: item.shortTitle,
unit: item.unit,
color: chartColor(item.accent),
visible: visibleKeys.includes(item.key),
data: item.values.map((point) => ({
timestamp: point.timestamp,
value: point.value,
})),
})),
};
function handleVariableToggle(variableKey: string) {
const key = variableKey as HistoryKey;
setVisibleKeys((current) => {
if (current.includes(key)) {
return current.length <= 1
? current
: current.filter((item) => item !== key);
}
return [...current, key];
});
}
return (
);
}
function chartColor(accent: Accent) {
switch (accent) {
case "amber":
return "#facc15";
case "blue":
return "#38bdf8";
case "cyan":
return "#67e8f9";
case "emerald":
return "#34d399";
case "violet":
return "#a78bfa";
}
}
function CompactMeteoChart({
children,
}: {
children: React.ReactNode;
}) {
return (
{children}
);
}
function HeroMetric({
title,
value,
helper,
icon: Icon,
}: {
title: string;
value: string;
helper: string;
icon: LucideIcon;
}) {
return (
);
}
function SummaryTile({
theme,
icon: Icon,
accent,
label,
value,
badge,
}: {
theme: "dark" | "light";
icon: LucideIcon;
accent: Accent;
label: string;
value: string;
badge: string;
}) {
const isDark = theme === "dark";
const colors = accentColors(accent);
return (
{value}
{badge}
);
}
function SmallStat({
theme,
label,
value,
}: {
theme: "dark" | "light";
label: string;
value: string;
}) {
const isDark = theme === "dark";
return (
);
}
function CompassLabel({
label,
className,
isDark,
}: {
label: string;
className: string;
isDark: boolean;
}) {
return (
{label}
);
}
function PanelState({
theme,
children,
}: {
theme: "dark" | "light";
children: string;
}) {
const isDark = theme === "dark";
return (
{children}
);
}
function HeroState({ children }: { children: string }) {
return (
{children}
);
}
function panelClass(isDark: boolean) {
return isDark
? `${RADIUS} flex h-full flex-col border border-white/10 bg-[#071421] p-3 shadow-[0_14px_34px_rgba(0,0,0,0.22)]`
: `${RADIUS} flex h-full flex-col border border-slate-200 bg-white p-3 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`;
}
function panelTitleClass(isDark: boolean) {
return isDark
? "text-base font-black text-slate-100"
: "text-base font-black text-slate-950";
}
function calculateDewPoint(
temperature: number | null,
humidity: number | null,
) {
if (temperature === null || humidity === null || humidity <= 0) return null;
return temperature - (100 - humidity) / 5;
}
function historianSeries(points: HistorianPoint[] | undefined) {
return (points ?? [])
.filter(
(point): point is HistorianPoint & { numericValue: number } =>
point.numericValue !== null,
)
.map((point) => ({
timestamp: point.timestamp,
value: point.numericValue,
}));
}
function weatherBackgroundForCondition(condition: string | null | undefined) {
const normalized = normalizeWeatherCondition(condition);
if (!normalized) return sunnyBackground;
if (
normalized.includes("thunder") ||
normalized.includes("trovoada") ||
normalized.includes("relampago") ||
normalized.includes("storm")
) {
return thunderstormBackground;
}
if (
normalized.includes("snowstorm") ||
normalized.includes("blizzard") ||
normalized.includes("heavy snow") ||
normalized.includes("neve forte") ||
normalized.includes("tempestade de neve")
) {
return snowstormBackground;
}
if (normalized.includes("snow") || normalized.includes("neve")) {
return snowBackground;
}
if (
normalized.includes("patchy rain") ||
normalized.includes("rain nearby") ||
normalized.includes("possibilidade de chuva") ||
normalized.includes("aguaceiros fracos")
) {
return patchyRainBackground;
}
if (
normalized.includes("rain") ||
normalized.includes("chuva") ||
normalized.includes("aguaceiro") ||
normalized.includes("drizzle") ||
normalized.includes("chuvisco")
) {
return rainyBackground;
}
if (
normalized.includes("fog") ||
normalized.includes("mist") ||
normalized.includes("nevoeiro") ||
normalized.includes("neblina")
) {
return foggyBackground;
}
if (normalized.includes("overcast") || normalized.includes("encoberto")) {
return overcastBackground;
}
if (
normalized.includes("partly cloudy") ||
normalized.includes("parcialmente nublado") ||
normalized.includes("partly") ||
normalized.includes("parcialmente")
) {
return partlyCloudyBackground;
}
if (normalized.includes("cloudy") || normalized.includes("nublado")) {
return cloudyBackground;
}
return sunnyBackground;
}
function normalizeWeatherCondition(condition: string | null | undefined) {
return (condition ?? "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase();
}
function formatNumber(value: number | null | undefined, decimals = 0) {
if (value === null || value === undefined || Number.isNaN(value)) return "--";
return Number.isInteger(value) || decimals === 0
? String(Math.round(value))
: value.toFixed(decimals);
}
function directionName(direction: number | null) {
if (direction === null) return "--";
const labels = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
const index = Math.round(direction / 45) % 8;
return labels[index];
}
function weekday(date: string) {
return new Date(date)
.toLocaleDateString("pt-PT", { weekday: "long" })
.replace(/^./, (letter) => letter.toUpperCase());
}
function shortDate(date: string) {
return new Date(date)
.toLocaleDateString("pt-PT", { day: "2-digit", month: "short" })
.replace(".", "");
}
function temperatureBadge(value: number | null) {
if (value === null) return "Sem dados";
if (value >= 30) return "Quente";
if (value <= 10) return "Frio";
return "Normal";
}
function humidityBadge(value: number | null) {
if (value === null) return "Sem dados";
if (value >= 80) return "Alta";
if (value <= 35) return "Baixa";
return "Normal";
}
function windBadge(value: number | null) {
if (value === null) return "Sem dados";
if (value >= 30) return "Forte";
if (value >= 10) return "Moderado";
return "Fraco";
}
function uvLabel(value: number | null | undefined) {
if (value === null || value === undefined) return "--";
if (value >= 11) return "Extremo";
if (value >= 8) return "Muito elevado";
if (value >= 6) return "Elevado";
if (value >= 3) return "Moderado";
return "Baixo";
}
function accentColors(accent: Accent) {
switch (accent) {
case "amber":
return {
text: "text-amber-300",
stroke: "stroke-amber-300",
fill: "fill-amber-300",
bg: "bg-amber-300",
};
case "blue":
return {
text: "text-sky-400",
stroke: "stroke-sky-400",
fill: "fill-sky-400",
bg: "bg-sky-400",
};
case "cyan":
return {
text: "text-cyan-300",
stroke: "stroke-cyan-300",
fill: "fill-cyan-300",
bg: "bg-cyan-300",
};
case "emerald":
return {
text: "text-emerald-400",
stroke: "stroke-emerald-400",
fill: "fill-emerald-400",
bg: "bg-emerald-400",
};
case "violet":
return {
text: "text-violet-400",
stroke: "stroke-violet-400",
fill: "fill-violet-400",
bg: "bg-violet-400",
};
}
}
function displayUnit(unit: string | null | undefined) {
if (!unit) return "";
const normalized = unit.trim();
if (normalized === "C" || normalized === "ºC") return "°C";
if (normalized === "w/m2" || normalized === "W/m2") return "W/m²";
return normalized;
}