finished meteorologia and main dashboard

This commit is contained in:
litoral05
2026-05-25 16:37:00 +01:00
parent b1bcf44f0f
commit d7ef36fc53
16 changed files with 2202 additions and 1167 deletions
+262 -451
View File
@@ -1,512 +1,323 @@
import {
Activity,
CloudRain,
Droplets,
Fan,
Lightbulb,
ArrowRight,
Cloud,
Leaf,
MapPin,
ShieldCheck,
Sprout,
Sun,
Thermometer,
Wind,
Zap,
Users,
} from "lucide-react";
import { MetricCard } from "../../../components/cards/MetricCard";
import { DashboardTrendChart } from "../components/DashboardTrendChart";
import { useDashboardOverviewStream } from "../hooks/useDashboardOverviewStream";
import { useHistorianDashboard } from "../hooks/useHistorianDashboard";
import backgroundImage from "../../../assets/background.png";
import farmdrawImage from "../../../assets/farmdraw.png";
import farmdrawWhiteImage from "../../../assets/farm-draw.png";
import { WeatherForecastCard } from "../../meteo/components/WeatherForecastCard";
import { useWeatherForecast } from "../../meteo/hooks/useWeatherForecast";
type DashboardPageProps = {
theme: "dark" | "light";
onOpenMeteo: () => void;
};
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 RADIUS = "rounded-[5px]";
export function DashboardPage({ theme, onOpenMeteo }: DashboardPageProps) {
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;
const weather = useWeatherForecast();
return (
<div className="space-y-6">
<section className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-5">
<MetricCard
title="Temperatura Exterior"
value={formatNumber(meteo?.exteriorTemperature)}
unit="°C"
icon={Thermometer}
theme={theme}
accent="red"
/>
<div
className={
isDark
? "relative h-full overflow-hidden text-slate-100"
: "relative h-full overflow-hidden text-slate-950"
}
>
<img
src={backgroundImage}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
<MetricCard
title="Humidade Exterior"
value={formatNumber(meteo?.exteriorHumidity, 0)}
unit="%"
icon={Droplets}
theme={theme}
accent="cyan"
/>
<div
className={
isDark
? "absolute inset-0 bg-[#0B1220]/34"
: "absolute inset-0 bg-white/8"
}
/>
<MetricCard
title="Radiação Solar"
value={formatNumber(meteo?.radiation, 0)}
unit="W/m²"
icon={Sun}
theme={theme}
accent="yellow"
/>
<div
className={
isDark
? "absolute inset-0 bg-[linear-gradient(90deg,#0B1220_0%,#0B1220_16%,rgba(11,18,32,0.94)_26%,rgba(11,18,32,0.62)_44%,rgba(11,18,32,0.20)_70%,rgba(11,18,32,0.02)_100%)]"
: "absolute inset-0 bg-[linear-gradient(90deg,#ffffff_0%,rgba(255,255,255,0.98)_12%,rgba(255,255,255,0.78)_28%,rgba(255,255,255,0.34)_48%,rgba(255,255,255,0.08)_70%,rgba(255,255,255,0)_100%)]"
}
/>
<MetricCard
title="Velocidade do Vento"
value={formatNumber(meteo?.windSpeed, 0)}
unit="Km/h"
icon={Wind}
theme={theme}
accent="blue"
/>
<main className="relative z-10 grid h-full w-full grid-rows-[minmax(330px,1.25fr)_118px_minmax(310px,1fr)_22px] gap-5 px-14 pb-3 pt-10">
<section className="relative min-h-0 pb-10">
<div className="max-w-[620px] pt-5">
<h1
className={
isDark
? "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-white"
: "text-[52px] font-black leading-[1.03] tracking-[-0.06em] text-slate-950"
}
>
Bem-vindo ao
<br />
<span className="text-emerald-400">Litoral Central</span>
</h1>
<MetricCard
title="Chuva"
value={formatBoolean(meteo?.raining)}
unit=""
icon={CloudRain}
theme={theme}
accent="green"
/>
</section>
<div className="mt-7 h-[3px] w-12 bg-emerald-400" />
<section className="grid grid-cols-1 gap-5 lg:grid-cols-4">
<DashboardModuleCard
theme={theme}
title="Clima"
icon={Fan}
main={`${onlineZoneCount} / ${zones.length}`}
label="zonas com dados"
accent="cyan"
items={[
["Temp. média", `${formatNumber(averageZoneTemperature)} °C`],
["CO₂ Zona 1", `${formatNumber(zoneOne?.co2, 0)} ppm`],
[
"Zenitais Zona 1",
`${formatNumber(zoneOne?.zenitalLeftPercent, 0)}% / ${formatNumber(
zoneOne?.zenitalRightPercent,
0,
)}%`,
],
]}
/>
<p
className={
isDark
? "mt-7 max-w-[500px] text-[15px] leading-7 text-slate-300"
: "mt-7 max-w-[500px] text-[15px] leading-7 text-slate-700"
}
>
A sua plataforma inteligente para gestão de operações agrícolas.
Acompanhe as condições, controle os sistemas e maximize a
eficiência no campo.
</p>
<DashboardModuleCard
theme={theme}
title="Rega"
icon={Droplets}
main={`${overview?.irrigation.activeValveCount ?? 0}`}
label={`válvulas ativas / ${overview?.irrigation.controllerCount ?? 0
} controladores`}
accent="green"
items={[
[
"Bombas ativas",
`${overview?.irrigation.activePumpCount ?? 0}`,
],
[
"Estado",
overview?.irrigation.activeValveCount ? "A regar" : "Parada",
],
]}
/>
<button
type="button"
onClick={onOpenMeteo}
className={`mt-8 inline-flex h-13 items-center gap-8 ${RADIUS} border border-emerald-400/45 bg-emerald-500/10 px-7 text-sm font-bold shadow-[0_12px_30px_rgba(16,185,129,0.12)] transition hover:bg-emerald-500/15 ${isDark ? "text-white" : "text-emerald-700"
}`}
>
Explorar plataforma
<ArrowRight className="h-5 w-5 text-emerald-400" />
</button>
</div>
<DashboardModuleCard
theme={theme}
title="Iluminação"
icon={Lightbulb}
main={`${overview?.lighting.activeSectorCount ?? 0}`}
label={`setores ativos / ${overview?.lighting.sectorCount ?? 0
} setores`}
accent="yellow"
items={[
[
"Estado",
overview?.lighting.activeSectorCount ? "Ligada" : "Desligada",
],
]}
/>
<WeatherForecastCard
compact
theme={theme}
forecast={weather.forecast}
loading={weather.loading}
error={weather.error}
onOpenMeteo={onOpenMeteo}
/>
</section>
<DashboardModuleCard
theme={theme}
title="Sistema"
icon={Zap}
main={connected ? "Online" : "Offline"}
label="ligação em tempo real"
accent="blue"
items={[
["PLC", connected ? "Comunicando" : "Sem ligação"],
["Historiador", historianData ? "Ativo" : "A carregar"],
]}
/>
</section>
<section className="grid min-h-0 grid-cols-3 gap-5">
<InfoCard
theme={theme}
icon={<Sprout className="h-7 w-7" />}
iconClass="bg-emerald-500/10 text-emerald-400"
title="Gestão inteligente"
text="Monitorize e controle todos os seus sistemas de forma centralizada."
/>
<section className="grid grid-cols-1 gap-5 xl:grid-cols-2">
<DashboardTrendChart
title="Meteorologia em Tempo Real"
subtitle="Temperatura exterior, humidade e radiação nos últimos 30 minutos."
theme={theme}
data={historianData}
series={[
{
key: "meteo.exterior_temperature",
label: "Temp. Exterior",
color: "#fb7185",
},
{
key: "meteo.exterior_humidity",
label: "Humidade",
color: "#22d3ee",
},
{
key: "meteo.radiation",
label: "Radiação",
color: "#facc15",
},
]}
/>
<InfoCard
theme={theme}
icon={<Leaf className="h-7 w-7" />}
iconClass="bg-sky-500/10 text-sky-400"
title="Eficiência hídrica"
text="Otimize o uso de água e promova uma agricultura sustentável."
/>
<DashboardTrendChart
title="Clima — Zona 1"
subtitle="Temperatura e humidade da zona principal nos últimos 30 minutos."
theme={theme}
data={historianData}
series={[
{
key: "climate.zone_1.temperature",
label: "Temp. Zona 1",
color: "#38bdf8",
},
{
key: "climate.zone_1.humidity",
label: "Humidade Zona 1",
color: "#2dd4bf",
},
]}
/>
</section>
<InfoCard
theme={theme}
icon={<Sun className="h-7 w-7" />}
iconClass="bg-yellow-400/10 text-yellow-300"
title="Decisões informadas"
text="Dados meteorológicos e insights para melhores decisões no campo."
/>
</section>
<section className="grid grid-cols-1 gap-5 xl:grid-cols-12">
<div
<section
className={
isDark
? "xl:col-span-4 rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
: "xl:col-span-4 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
? `min-h-0 overflow-visible ${RADIUS} border border-white/10 bg-[#07111f]/78 p-8 shadow-[0_18px_50px_rgba(0,0,0,0.28)] backdrop-blur-md`
: `min-h-0 overflow-visible ${RADIUS} border border-slate-200/90 bg-white/94 p-8 shadow-[0_14px_40px_rgba(15,23,42,0.06)] backdrop-blur-md`
}
>
<div className="mb-5 flex items-center justify-between">
<div className="grid h-full grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)] items-center gap-2">
<div>
<p
className={
isDark
? "text-xs font-bold uppercase tracking-[0.28em] text-slate-400"
: "text-xs font-bold uppercase tracking-[0.28em] text-emerald-700"
}
>
A Nossa Missão
</p>
<h2
className={
isDark
? "text-base font-semibold text-white"
: "text-base font-semibold text-slate-950"
? "mt-4 max-w-[640px] text-[30px] font-black leading-tight tracking-[-0.045em] text-white"
: "mt-4 max-w-[640px] text-[30px] font-black leading-tight tracking-[-0.045em] text-slate-950"
}
>
Estado Operacional
Soluções inovadoras para
<br />
uma{" "}
<span className="text-emerald-400">
agricultura sustentável
</span>
</h2>
<p
className={
isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"
isDark
? "mt-4 max-w-[560px] text-sm leading-6 text-slate-400"
: "mt-4 max-w-[560px] text-sm leading-6 text-slate-600"
}
>
Resumo rápido da instalação.
Tecnologia, conhecimento e proximidade para impulsionar o futuro
do setor agrícola em Portugal.
</p>
<div
className={
isDark
? "mt-6 grid grid-cols-3 gap-5 border-t border-white/10 pt-6"
: "mt-6 grid grid-cols-3 gap-5 border-t border-slate-200 pt-6"
}
>
<MissionItem
theme={theme}
icon={<Sprout />}
title="Sustentabilidade"
text="Compromisso com o futuro"
/>
<MissionItem
theme={theme}
icon={<ShieldCheck />}
title="Confiabilidade"
text="Tecnologia robusta e segura"
/>
<MissionItem
theme={theme}
icon={<Users />}
title="Apoio próximo"
text="Sempre ao seu lado"
/>
</div>
</div>
<Activity
className={isDark ? "h-5 w-5 text-slate-400" : "h-5 w-5 text-slate-500"}
/>
<FarmIllustration theme={theme} />
</div>
</section>
<div className="space-y-3">
<StatusRow
theme={theme}
label="Aquisição"
value={connected ? "Ativa" : "Inativa"}
good={connected}
/>
<StatusRow
theme={theme}
label="Rega"
value={overview?.irrigation.activeValveCount ? "Em curso" : "Parada"}
good={!overview?.irrigation.activeValveCount}
/>
<StatusRow
theme={theme}
label="Chuva"
value={formatBoolean(meteo?.raining)}
good={!meteo?.raining}
/>
<StatusRow
theme={theme}
label="Zenitais Zona 1"
value={`${formatNumber(zoneOne?.zenitalLeftPercent, 0)}%`}
good
/>
</div>
</div>
<div
className={
isDark
? "xl:col-span-8 rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
: "xl:col-span-8 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
}
>
<div className="mb-5">
<h2
className={
isDark
? "text-base font-semibold text-white"
: "text-base font-semibold text-slate-950"
}
>
Próximos Desenvolvimentos
</h2>
<p
className={
isDark ? "text-sm text-slate-400" : "text-sm text-slate-500"
}
>
Espaço preparado para eventos, alarmes, programas e depósitos.
</p>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<PlaceholderPanel
theme={theme}
title="Eventos"
text="Últimos eventos do sistema"
/>
<PlaceholderPanel
theme={theme}
title="Alarmes"
text="Alarmes ativos e recentes"
/>
<PlaceholderPanel
theme={theme}
title="Depósitos"
text="Níveis, pH e CE"
/>
</div>
</div>
</section>
<footer className="flex min-h-0 items-center justify-between px-56 text-xs text-slate-500">
<span>© 2026 Litoral Central. Todos os direitos reservados.</span>
<span>
Feito em Portugal <span className="ml-2">🇵🇹</span>
</span>
</footer>
</main>
</div>
);
}
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({
function InfoCard({
theme,
icon,
iconClass,
title,
icon: Icon,
main,
label,
accent,
items,
}: DashboardModuleCardProps) {
const isDark = theme === "dark";
const accentClass = moduleAccentClasses[accent];
return (
<div
className={
isDark
? "rounded-2xl border border-white/10 bg-[#142230] p-5 shadow-[0_16px_40px_rgba(0,0,0,0.22)]"
: "rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
}
>
<div className="mb-5 flex items-center justify-between">
<h2
className={
isDark
? "text-sm font-medium text-slate-300"
: "text-sm font-medium text-slate-600"
}
>
{title}
</h2>
<div
className={`flex h-10 w-10 items-center justify-center rounded-xl ${accentClass.bg} ${accentClass.icon}`}
>
<Icon className="h-5 w-5" />
</div>
</div>
<p
className={
isDark
? "text-3xl font-bold tracking-tight text-white"
: "text-3xl font-bold tracking-tight text-slate-950"
}
>
{main}
</p>
<p className={isDark ? "mt-1 text-sm text-slate-400" : "mt-1 text-sm text-slate-500"}>
{label}
</p>
<div className="mt-5 space-y-2">
{items.map(([key, value]) => (
<div key={key} className="flex justify-between gap-4 text-sm">
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
{key}
</span>
<span
className={
isDark
? "font-medium text-slate-100"
: "font-medium text-slate-800"
}
>
{value}
</span>
</div>
))}
</div>
</div>
);
}
type StatusRowProps = {
theme: "dark" | "light";
label: string;
value: string;
good: boolean;
};
function StatusRow({ theme, label, value, good }: StatusRowProps) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? "flex items-center justify-between rounded-xl bg-white/5 px-4 py-3"
: "flex items-center justify-between rounded-xl bg-slate-50 px-4 py-3"
}
>
<span className={isDark ? "text-sm text-slate-300" : "text-sm text-slate-600"}>
{label}
</span>
<span
className={
good
? "rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-500"
: "rounded-full bg-red-500/10 px-3 py-1 text-xs font-semibold text-red-500"
}
>
{value}
</span>
</div>
);
}
type PlaceholderPanelProps = {
text,
}: {
theme: "dark" | "light";
icon: React.ReactNode;
iconClass: string;
title: string;
text: string;
};
function PlaceholderPanel({ theme, title, text }: PlaceholderPanelProps) {
}) {
const isDark = theme === "dark";
return (
<div
<article
className={
isDark
? "rounded-xl border border-white/10 bg-white/5 p-4"
: "rounded-xl border border-slate-200 bg-slate-50 p-4"
? `${RADIUS} flex h-full items-center justify-between border border-white/10 bg-[#07111f]/78 p-5 shadow-[0_18px_50px_rgba(0,0,0,0.24)] backdrop-blur-md`
: `${RADIUS} flex h-full items-center justify-between border border-slate-200/90 bg-white/95 p-5 shadow-[0_8px_24px_rgba(15,23,42,0.05)] backdrop-blur-md`
}
>
<p
className={
isDark
? "text-sm font-semibold text-white"
: "text-sm font-semibold text-slate-950"
}
>
{title}
</p>
<p className={isDark ? "mt-1 text-sm text-slate-400" : "mt-1 text-sm text-slate-500"}>
{text}
</p>
<div className="flex items-center gap-5">
<div
className={`grid h-[62px] w-[62px] place-items-center ${RADIUS} ${iconClass}`}
>
{icon}
</div>
<div>
<h3 className="text-base font-black">{title}</h3>
<p
className={
isDark
? "mt-2 max-w-[280px] text-sm leading-5 text-slate-400"
: "mt-2 max-w-[280px] text-sm leading-5 text-slate-600"
}
>
{text}
</p>
</div>
</div>
<ArrowRight
className={isDark ? "h-5 w-5 text-slate-400" : "h-5 w-5 text-emerald-500"}
/>
</article>
);
}
function MissionItem({
theme,
icon,
title,
text,
}: {
theme: "dark" | "light";
icon: React.ReactNode;
title: string;
text: string;
}) {
const isDark = theme === "dark";
return (
<div className="flex items-center gap-3">
<div className="h-7 w-7 text-emerald-400">{icon}</div>
<div>
<h4 className="text-sm font-black">{title}</h4>
<p
className={
isDark
? "mt-1 text-xs text-slate-400"
: "mt-1 text-xs text-slate-600"
}
>
{text}
</p>
</div>
</div>
);
}
}
function FarmIllustration({ theme }: { theme: "dark" | "light" }) {
const isDark = theme === "dark";
return (
<div className="relative h-full min-h-[270px] w-full overflow-visible">
<img
src={isDark ? farmdrawImage : farmdrawWhiteImage}
alt=""
className={
isDark
? "absolute right-[-70px] top-1/2 h-[380px] max-w-none -translate-y-1/2 opacity-95"
: "absolute right-[-70px] top-1/2 h-[380px] max-w-none -translate-y-1/2 opacity-100"
}
/>
</div>
);
}
export default DashboardPage;
@@ -26,6 +26,8 @@ type Props = {
onClose: () => void;
};
const RADIUS = "rounded-[5px]";
const RANGE_OPTIONS: Array<{ label: string; value: AccumulatedRange }> = [
{ label: "7D", value: "7d" },
{ label: "30D", value: "30d" },
@@ -44,6 +46,7 @@ export function AccumulatedHistoryModal({
onClose,
}: Props) {
const isDark = theme === "dark";
const palette = accumulatedPalette(isDark);
const [mode, setMode] = useState<"chart" | "table">("chart");
const stats = useMemo(() => {
@@ -62,26 +65,34 @@ export function AccumulatedHistoryModal({
if (!sensor) return null;
const unit = sensor.unit ?? buckets[0]?.unit ?? "";
const unit = buckets[0]?.unit ?? sensor.unit ?? "";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-md">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div
className={
isDark
? "flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-[#24384d] bg-[#071120] text-white shadow-2xl"
: "flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-950 shadow-2xl"
? `${RADIUS} flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden border border-white/10 bg-[#111827] text-slate-100 shadow-2xl`
: `${RADIUS} flex h-[82vh] w-full max-w-6xl flex-col overflow-hidden border border-slate-200 bg-white text-slate-950 shadow-[0_24px_70px_rgba(15,23,42,0.18)]`
}
>
<header className="flex items-start justify-between gap-5 px-6 py-4">
<div>
<p className="mb-1 text-[11px] font-black uppercase tracking-[0.42em] text-cyan-400">
<p
className={
isDark
? "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500"
: "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400"
}
>
Acumulado
</p>
<h2 className="text-xl font-black">{title}</h2>
<h2 className="text-xl font-black tracking-[-0.03em]">
{title}
</h2>
<p className="mt-1 text-xs text-slate-400">
<p className="mt-1 text-xs text-slate-500">
Chave: meteo.{sensor.key}
</p>
</div>
@@ -89,7 +100,11 @@ export function AccumulatedHistoryModal({
<button
type="button"
onClick={onClose}
className="p-2 text-slate-400 hover:text-white"
className={
isDark
? `${RADIUS} p-2 text-slate-400 transition hover:bg-white/5 hover:text-white`
: `${RADIUS} p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-900`
}
>
<X className="h-5 w-5" />
</button>
@@ -99,8 +114,8 @@ export function AccumulatedHistoryModal({
<section
className={
isDark
? "flex min-h-0 flex-1 flex-col rounded-xl border border-[#24384d] bg-[#0a1728] p-4"
: "flex min-h-0 flex-1 flex-col rounded-xl border border-slate-200 bg-slate-50 p-4"
? `${RADIUS} flex min-h-0 flex-1 flex-col border border-white/10 bg-[#0b1220] p-4`
: `${RADIUS} flex min-h-0 flex-1 flex-col border border-slate-200 bg-slate-50 p-4`
}
>
<div className="mb-4 flex flex-wrap items-center gap-2">
@@ -111,10 +126,8 @@ export function AccumulatedHistoryModal({
onClick={() => onRangeChange(option.value)}
className={
range === option.value
? "rounded-lg bg-cyan-400 px-4 py-2 text-xs font-black text-slate-950 shadow-lg shadow-cyan-400/20"
: isDark
? "rounded-lg bg-slate-900/70 px-4 py-2 text-xs font-bold text-slate-300 hover:bg-slate-800"
: "rounded-lg bg-white px-4 py-2 text-xs font-bold text-slate-600 hover:bg-slate-100"
? activeButtonClass(isDark)
: buttonClass(isDark)
}
>
{option.label}
@@ -142,7 +155,7 @@ export function AccumulatedHistoryModal({
</div>
</div>
<div className="mb-4 grid grid-cols-3 gap-3">
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard theme={theme} label="Total" value={formatValue(stats.total, unit)} />
<StatCard theme={theme} label="Média" value={formatValue(stats.average, unit)} />
<StatCard theme={theme} label="Máximo" value={formatValue(stats.max, unit)} />
@@ -155,35 +168,41 @@ export function AccumulatedHistoryModal({
<EmptyState>Sem dados acumulados para este período.</EmptyState>
) : mode === "chart" ? (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets}>
<BarChart
data={buckets}
margin={{ top: 16, right: 18, bottom: 8, left: 0 }}
>
<CartesianGrid
stroke="#1f3348"
strokeDasharray="3 3"
stroke={palette.grid}
strokeDasharray="4 6"
vertical={false}
/>
<XAxis
dataKey="label"
tick={{ fill: "#94a3b8", fontSize: 11 }}
tick={{ fill: palette.axis, fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#334155" }}
axisLine={{ stroke: palette.axisLine }}
minTickGap={20}
/>
<YAxis
tick={{ fill: "#94a3b8", fontSize: 11 }}
tick={{ fill: palette.axis, fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#334155" }}
width={50}
axisLine={{ stroke: palette.axisLine }}
width={56}
/>
<Tooltip
cursor={{ fill: "rgba(34, 211, 238, 0.08)" }}
cursor={{ fill: palette.cursor }}
contentStyle={{
background: "#111827",
border: "1px solid #334155",
borderRadius: "10px",
color: "#fff",
background: palette.tooltipBg,
border: `1px solid ${palette.tooltipBorder}`,
borderRadius: "5px",
color: palette.tooltipText,
boxShadow: isDark
? "0 18px 45px rgba(0,0,0,0.30)"
: "0 18px 45px rgba(15,23,42,0.14)",
}}
formatter={(value) => [
formatValue(Number(value), unit),
@@ -193,16 +212,28 @@ export function AccumulatedHistoryModal({
<Bar
dataKey="total"
fill="#22d3ee"
radius={[6, 6, 0, 0]}
fill={palette.bar}
radius={[3, 3, 0, 0]}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-full overflow-auto rounded-xl border border-slate-700/60">
<div
className={
isDark
? `${RADIUS} h-full overflow-auto border border-white/10 bg-[#111827]`
: `${RADIUS} h-full overflow-auto border border-slate-200 bg-white`
}
>
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-[#0b1828] text-xs uppercase tracking-[0.16em] text-slate-400">
<thead
className={
isDark
? "sticky top-0 bg-[#111827] text-xs uppercase tracking-[0.14em] text-slate-500"
: "sticky top-0 bg-slate-50 text-xs uppercase tracking-[0.14em] text-slate-500"
}
>
<tr>
<th className="px-4 py-3">Período</th>
<th className="px-4 py-3">Início</th>
@@ -215,18 +246,31 @@ export function AccumulatedHistoryModal({
{buckets.map((bucket) => (
<tr
key={`${bucket.from}-${bucket.to}`}
className="border-t border-slate-700/60 text-slate-200"
className={
isDark
? "border-t border-white/10 text-slate-300"
: "border-t border-slate-200 text-slate-700"
}
>
<td className="px-4 py-3 font-semibold">
{bucket.label}
</td>
<td className="px-4 py-3 text-slate-400">
<td className="px-4 py-3 text-slate-500">
{formatDate(bucket.from)}
</td>
<td className="px-4 py-3 text-slate-400">
<td className="px-4 py-3 text-slate-500">
{formatDate(bucket.to)}
</td>
<td className="px-4 py-3 text-right font-black text-cyan-300">
<td
className={
isDark
? "px-4 py-3 text-right font-black text-slate-100"
: "px-4 py-3 text-right font-black text-slate-950"
}
>
{formatValue(bucket.total, unit)}
</td>
</tr>
@@ -243,6 +287,30 @@ export function AccumulatedHistoryModal({
);
}
function accumulatedPalette(isDark: boolean) {
return isDark
? {
bar: "#94a3b8",
grid: "rgba(148,163,184,0.16)",
axis: "#64748b",
axisLine: "rgba(148,163,184,0.18)",
cursor: "rgba(148,163,184,0.08)",
tooltipBg: "#111827",
tooltipBorder: "rgba(255,255,255,0.10)",
tooltipText: "#e5e7eb",
}
: {
bar: "#475569",
grid: "#e2e8f0",
axis: "#64748b",
axisLine: "#cbd5e1",
cursor: "rgba(148,163,184,0.12)",
tooltipBg: "#ffffff",
tooltipBorder: "#e2e8f0",
tooltipText: "#0f172a",
};
}
function EmptyState({ children }: { children: string }) {
return (
<div className="flex h-full items-center justify-center text-sm text-slate-400">
@@ -266,27 +334,47 @@ function StatCard({
<div
className={
isDark
? "rounded-xl border border-slate-700/60 bg-[#071120] p-3"
: "rounded-xl border border-slate-200 bg-white p-3"
? `${RADIUS} border border-white/10 bg-[#111827] p-3`
: `${RADIUS} border border-slate-200 bg-white p-3`
}
>
<p className="text-xs text-slate-400">{label}</p>
<p className="mt-1 text-xl font-black">{value}</p>
<p className="text-xs text-slate-500">{label}</p>
<p
className={
isDark
? "mt-1 text-xl font-black text-slate-100"
: "mt-1 text-xl font-black text-slate-950"
}
>
{value}
</p>
</div>
);
}
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";
}
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 toggleButtonClass(isDark: boolean, active: boolean) {
if (active) return activeButtonClass(isDark);
return buttonClass(isDark);
}
function formatValue(value: number, unit: string) {
if (unit === "Wh/m²" && value >= 1000) {
return `${(value / 1000).toFixed(2)} kWh/m²`;
}
return `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`;
}
@@ -18,7 +18,7 @@ import {
ResponsiveContainer,
Tooltip,
XAxis,
YAxis
YAxis,
} from "recharts";
import type { ModuleSensorResponse } from "../../../types/meteo";
@@ -39,6 +39,8 @@ type Props = {
onClose: () => void;
};
const RADIUS = "rounded-[5px]";
const RANGE_OPTIONS = [
{ label: "15M", hours: 0.25 },
{ label: "30M", hours: 0.5 },
@@ -64,6 +66,7 @@ export function MeteoHistoryModal({
onClose,
}: Props) {
const isDark = theme === "dark";
const palette = chartPalette(isDark);
const [intervalMinutes, setIntervalMinutes] = useState(5);
const [showIndicators, setShowIndicators] = useState(true);
@@ -71,7 +74,6 @@ export function MeteoHistoryModal({
const [chartMode, setChartMode] = useState<"area" | "line">("area");
const [zeroBaseline, setZeroBaseline] = useState(false);
const [expanded, setExpanded] = useState(false);
const [intervalOpen, setIntervalOpen] = useState(false);
const rawData = useMemo(
@@ -80,11 +82,7 @@ export function MeteoHistoryModal({
.map((point) => {
const value =
point.numericValue ??
(point.booleanValue === null
? null
: point.booleanValue
? 1
: 0);
(point.booleanValue === null ? null : point.booleanValue ? 1 : 0);
return {
timestamp: point.timestamp,
@@ -92,8 +90,14 @@ export function MeteoHistoryModal({
value,
};
})
.filter((point): point is { timestamp: string; date: Date; value: number } =>
typeof point.value === "number",
.filter(
(
point,
): point is {
timestamp: string;
date: Date;
value: number;
} => typeof point.value === "number",
),
[points],
);
@@ -101,7 +105,11 @@ export function MeteoHistoryModal({
const chartData = useMemo(() => {
if (rawData.length === 0) return [];
const buckets = new Map<number, { total: number; count: number; timestamp: string }>();
const buckets = new Map<
number,
{ total: number; count: number; timestamp: string }
>();
const intervalMs = intervalMinutes * 60 * 1000;
for (const point of rawData) {
@@ -177,43 +185,63 @@ export function MeteoHistoryModal({
if (!sensor) return null;
const unit = sensor.unit ?? "";
const yDomain: [number | "auto", number | "auto"] = zeroBaseline ? [0, "auto"] : ["auto", "auto"];
const yDomain: [number | "auto", number | "auto"] = zeroBaseline
? [0, "auto"]
: ["auto", "auto"];
const Chart = chartMode === "area" ? AreaChart : LineChart;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-md">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div
className={
isDark
? `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} flex w-full flex-col overflow-hidden rounded-2xl border border-[#24384d] bg-[#071120] text-white shadow-2xl`
: `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} flex w-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white text-slate-950 shadow-2xl`
? `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} ${RADIUS} flex w-full flex-col overflow-hidden border border-white/10 bg-[#111827] text-slate-100 shadow-2xl`
: `${expanded ? "h-[88vh] max-w-[92vw]" : "max-w-6xl"} ${RADIUS} flex w-full flex-col overflow-hidden border border-slate-200 bg-white text-slate-950 shadow-[0_24px_70px_rgba(15,23,42,0.18)]`
}
>
<header className="flex items-start justify-between gap-5 px-6 py-4">
<div>
<p className="mb-1 text-[11px] font-black uppercase tracking-[0.42em] text-cyan-400">
<p
className={
isDark
? "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-500"
: "mb-1 text-[11px] font-bold uppercase tracking-[0.22em] text-slate-400"
}
>
Histórico
</p>
<h2 className="text-xl font-black">{sensor.name}</h2>
<p className="mt-1 text-xs text-slate-400">
<h2 className="text-xl font-black tracking-[-0.03em]">
{sensor.name}
</h2>
<p className="mt-1 text-xs text-slate-500">
Chave: meteo.{sensor.key}
</p>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={onClose} className="p-2 text-slate-400 hover:text-white">
<X className="h-5 w-5" />
</button>
</div>
<button
type="button"
onClick={onClose}
className={
isDark
? `${RADIUS} p-2 text-slate-400 transition hover:bg-white/5 hover:text-white`
: `${RADIUS} p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-900`
}
>
<X className="h-5 w-5" />
</button>
</header>
<main className="flex min-h-0 flex-1 flex-col px-6 pb-5">
<section className={
isDark
? "flex min-h-0 flex-1 flex-col rounded-xl border border-[#24384d] bg-[#0a1728] p-4"
: "flex min-h-0 flex-1 flex-col rounded-xl border border-slate-200 bg-slate-50 p-4"
}>
<section
className={
isDark
? `${RADIUS} flex min-h-0 flex-1 flex-col border border-white/10 bg-[#0b1220] p-4`
: `${RADIUS} flex min-h-0 flex-1 flex-col border border-slate-200 bg-slate-50 p-4`
}
>
<div className="mb-4 flex flex-wrap items-center gap-2">
{RANGE_OPTIONS.map((range) => (
<button
@@ -222,10 +250,8 @@ export function MeteoHistoryModal({
onClick={() => onHoursChange(range.hours)}
className={
hours === range.hours
? "rounded-lg bg-cyan-400 px-4 py-2 text-xs font-black text-slate-950 shadow-lg shadow-cyan-400/20"
: isDark
? "rounded-lg bg-slate-900/70 px-4 py-2 text-xs font-bold text-slate-300 hover:bg-slate-800"
: "rounded-lg bg-white px-4 py-2 text-xs font-bold text-slate-600 hover:bg-slate-100"
? activeButtonClass(isDark)
: buttonClass(isDark)
}
>
{range.label}
@@ -236,16 +262,12 @@ export function MeteoHistoryModal({
<button
type="button"
onClick={() => setIntervalOpen((value) => !value)}
className={
isDark
? "flex h-[36px] items-center gap-2 rounded-lg border border-slate-700 bg-[#0a1728] px-4 text-xs font-semibold text-slate-200 hover:bg-slate-800"
: "flex h-[36px] items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 text-xs font-semibold text-slate-700 hover:bg-slate-100"
}
className={buttonClass(isDark)}
>
Intervalo: {intervalMinutes}m
<svg
className={`h-3 w-3 transition ${intervalOpen ? "rotate-180" : ""}`}
className={`ml-2 inline h-3 w-3 transition ${intervalOpen ? "rotate-180" : ""
}`}
viewBox="0 0 20 20"
fill="currentColor"
>
@@ -261,8 +283,8 @@ export function MeteoHistoryModal({
<div
className={
isDark
? "absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-slate-700 bg-[#0b1828] shadow-2xl"
: "absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xl"
? `absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden ${RADIUS} border border-white/10 bg-[#111827] shadow-2xl`
: `absolute left-0 top-[calc(100%+8px)] z-50 min-w-[160px] overflow-hidden ${RADIUS} border border-slate-200 bg-white shadow-xl`
}
>
{INTERVAL_OPTIONS.map((option) => (
@@ -275,22 +297,30 @@ export function MeteoHistoryModal({
}}
className={
intervalMinutes === option
? "flex w-full items-center justify-between bg-cyan-400/10 px-4 py-3 text-left text-xs font-bold text-cyan-300"
? isDark
? "flex w-full items-center justify-between bg-white/[0.06] px-4 py-3 text-left text-xs font-bold text-slate-100"
: "flex w-full items-center justify-between bg-slate-100 px-4 py-3 text-left text-xs font-bold text-slate-950"
: isDark
? "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-300 hover:bg-slate-800"
: "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-700 hover:bg-slate-100"
? "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-300 hover:bg-white/[0.04]"
: "flex w-full items-center justify-between px-4 py-3 text-left text-xs font-semibold text-slate-700 hover:bg-slate-50"
}
>
<span>{option} minutos</span>
{intervalMinutes === option && (
<div className="h-2 w-2 rounded-full bg-cyan-300" />
<div
className={
isDark
? "h-2 w-2 rounded-full bg-slate-300"
: "h-2 w-2 rounded-full bg-slate-700"
}
/>
)}
</button>
))}
</div>
)}
</div>
<button
type="button"
onClick={() => setShowCompareLine((value) => !value)}
@@ -310,15 +340,21 @@ export function MeteoHistoryModal({
</div>
<div className="mb-3 flex flex-wrap items-center gap-6 text-xs">
<span className="font-semibold">
<span className="mr-2 inline-block h-2.5 w-2.5 rounded-full bg-cyan-400" />
<span className={isDark ? "font-semibold text-slate-100" : "font-semibold text-slate-950"}>
<span className={isDark ? "mr-2 inline-block h-2.5 w-2.5 rounded-full bg-slate-300" : "mr-2 inline-block h-2.5 w-2.5 rounded-full bg-slate-700"} />
{sensor.name}
{unit && ` (${unit})`}
</span>
<span>Média: <b>{formatValue(stats.average, unit)}</b></span>
<span>Máx: <b>{formatValue(stats.max, unit)}</b></span>
<span>Mín: <b>{formatValue(stats.min, unit)}</b></span>
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
Média: <b>{formatValue(stats.average, unit)}</b>
</span>
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
Máx: <b>{formatValue(stats.max, unit)}</b>
</span>
<span className={isDark ? "text-slate-400" : "text-slate-500"}>
Mín: <b>{formatValue(stats.min, unit)}</b>
</span>
<div className="ml-auto flex gap-2">
<IconButton
@@ -342,8 +378,7 @@ export function MeteoHistoryModal({
</div>
</div>
<div className={expanded ? "min-h-0 flex-1" : "h-[300px]"}
>
<div className={expanded ? "min-h-0 flex-1" : "h-[300px]"}>
{loading ? (
<EmptyState>A carregar histórico...</EmptyState>
) : chartData.length === 0 ? (
@@ -353,32 +388,37 @@ export function MeteoHistoryModal({
<Chart data={chartData}>
<defs>
<linearGradient id="historyFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22d3ee" stopOpacity={0.34} />
<stop offset="95%" stopColor="#22d3ee" stopOpacity={0.03} />
<stop offset="0%" stopColor={palette.line} stopOpacity={isDark ? 0.22 : 0.12} />
<stop offset="70%" stopColor={palette.line} stopOpacity={isDark ? 0.08 : 0.04} />
<stop offset="100%" stopColor={palette.line} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="#1f3348" strokeDasharray="3 3" vertical={false} />
<CartesianGrid
stroke={palette.grid}
strokeDasharray="4 6"
vertical={false}
/>
<XAxis
dataKey="time"
tick={{ fill: "#94a3b8", fontSize: 11 }}
tick={{ fill: palette.axis, fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#334155" }}
axisLine={{ stroke: palette.axisLine }}
minTickGap={42}
/>
<YAxis
domain={yDomain}
tick={{ fill: "#94a3b8", fontSize: 11 }}
tick={{ fill: palette.axis, fontSize: 11 }}
tickLine={false}
axisLine={{ stroke: "#334155" }}
axisLine={{ stroke: palette.axisLine }}
width={44}
/>
<Tooltip
cursor={{
stroke: "#94a3b8",
stroke: palette.cursor,
strokeWidth: 1,
}}
content={({ active, payload, label }) => {
@@ -387,43 +427,52 @@ export function MeteoHistoryModal({
const value = Number(payload[0].value);
return (
<div className="rounded-xl border border-slate-700 bg-[#111827] px-4 py-3 text-sm shadow-2xl">
<p className="mb-2 text-xs text-slate-400">{label}</p>
<div
className={
isDark
? `${RADIUS} border border-white/10 bg-[#111827] px-4 py-3 text-sm shadow-2xl`
: `${RADIUS} border border-slate-200 bg-white px-4 py-3 text-sm text-slate-950 shadow-xl`
}
>
<p className="mb-2 text-xs text-slate-500">
{label}
</p>
<p className="font-bold text-cyan-300">
<p className={isDark ? "font-bold text-slate-100" : "font-bold text-slate-950"}>
Atual: {formatValue(value, unit)}
</p>
<p className="mt-1 text-slate-300">
<p className={isDark ? "mt-1 text-slate-400" : "mt-1 text-slate-500"}>
Média: {formatValue(stats.average, unit)}
</p>
<p className="text-slate-300">
<p className={isDark ? "text-slate-400" : "text-slate-500"}>
Máx: {formatValue(stats.max, unit)}
</p>
<p className="text-slate-300">
<p className={isDark ? "text-slate-400" : "text-slate-500"}>
Mín: {formatValue(stats.min, unit)}
</p>
</div>
);
}}
/>
{showIndicators && stats.average !== null && (
<ReferenceLine
y={stats.average}
stroke="#22d3ee"
strokeDasharray="4 4"
strokeOpacity={0.45}
stroke={palette.reference}
strokeDasharray="4 5"
strokeOpacity={0.42}
/>
)}
{showCompareLine && stats.current !== null && (
<ReferenceLine
y={stats.current}
stroke="#a78bfa"
stroke={palette.compare}
strokeDasharray="5 5"
strokeOpacity={0.55}
strokeOpacity={0.38}
/>
)}
@@ -431,14 +480,14 @@ export function MeteoHistoryModal({
<Area
type="monotone"
dataKey="value"
stroke="#22d3ee"
stroke={palette.line}
strokeWidth={2}
fill="url(#historyFill)"
dot={false}
activeDot={{
r: 5,
fill: "#22d3ee",
stroke: "#071120",
fill: palette.line,
stroke: isDark ? "#111827" : "#ffffff",
strokeWidth: 2,
}}
isAnimationActive={false}
@@ -447,13 +496,13 @@ export function MeteoHistoryModal({
<Line
type="monotone"
dataKey="value"
stroke="#22d3ee"
stroke={palette.line}
strokeWidth={2}
dot={false}
activeDot={{
r: 5,
fill: "#22d3ee",
stroke: "#071120",
fill: palette.line,
stroke: isDark ? "#111827" : "#ffffff",
strokeWidth: 2,
}}
isAnimationActive={false}
@@ -466,23 +515,31 @@ export function MeteoHistoryModal({
</section>
{showIndicators && (
<section
className="mt-3 grid shrink-0 grid-cols-5 gap-3"
>
<section className="mt-3 grid shrink-0 grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<MetricCard theme={theme} title="Valor atual" value={formatValue(stats.current, unit)} sub="Agora há pouco" />
<MetricCard theme={theme} title="Variação" value={formatSignedValue(stats.change, unit)} sub="No período" positive={(stats.change ?? 0) >= 0} />
<MetricCard theme={theme} title="Máxima" value={formatValue(stats.max, unit)} sub="No período" />
<MetricCard theme={theme} title="Mínima" value={formatValue(stats.min, unit)} sub="No período" />
<div className={cardClass(isDark)}>
<p className="text-xs text-slate-400">Volume de Dados</p>
<h3 className="mt-1 text-xl font-black">{stats.count.toLocaleString("pt-PT")}</h3>
<p className="text-xs text-slate-400">pontos</p>
<p className="text-xs text-slate-500">
Volume de Dados
</p>
<h3 className="mt-1 text-xl font-black">
{stats.count.toLocaleString("pt-PT")}
</h3>
<p className="text-xs text-slate-500">
pontos
</p>
<div className="mt-2 h-9">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={miniBars}>
<Bar dataKey="value" fill="#4f46e5" radius={[3, 3, 0, 0]} />
<Bar
dataKey="value"
fill={isDark ? "#94a3b8" : "#64748b"}
radius={[3, 3, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</div>
@@ -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 <div className="flex h-full items-center justify-center text-sm text-slate-400">{children}</div>;
return (
<div className="flex h-full items-center justify-center text-sm text-slate-400">
{children}
</div>
);
}
function IconButton({
@@ -522,7 +605,6 @@ function MetricCard({
title,
value,
sub,
positive,
}: {
theme: "dark" | "light";
title: string;
@@ -534,36 +616,41 @@ function MetricCard({
return (
<div className={cardClass(isDark)}>
<p className="text-xs text-slate-400">{title}</p>
<h3 className={`mt-1 text-xl font-black ${positive ? "text-emerald-400" : ""}`}>{value}</h3>
<p className="text-xs text-slate-400">{sub}</p>
<p className="text-xs text-slate-500">{title}</p>
<h3 className="mt-1 text-xl font-black">{value}</h3>
<p className="text-xs text-slate-500">{sub}</p>
</div>
);
}
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) {
@@ -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 (
<CompactWeatherCard
theme={theme}
forecast={forecast}
loading={loading}
error={error}
onOpenMeteo={onOpenMeteo}
/>
);
}
return (
<section
className={
isDark
? `${RADIUS} border border-white/10 bg-[#111827] p-5 shadow-[0_12px_30px_rgba(0,0,0,0.22)]`
: `${RADIUS} border border-slate-200 bg-white p-5 shadow-[0_10px_26px_rgba(15,23,42,0.06)]`
}
>
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<p className={isDark ? eyebrowDark : eyebrowLight}>
Previsão meteorológica
</p>
<h2 className={isDark ? titleDark : titleLight}>
{forecast
? `${forecast.location.name}, ${forecast.location.country}`
: "Meteorologia externa"}
</h2>
<p className={isDark ? subtitleDark : subtitleLight}>
<MapPin className="h-4 w-4" />
Dados externos de previsão
</p>
</div>
{forecast?.current.condition?.icon && (
<img
src={forecast.current.condition.icon}
alt={forecast.current.condition.text}
className="h-12 w-12 opacity-90"
/>
)}
</div>
{loading ? (
<StateMessage theme={theme}>A carregar previsão meteorológica...</StateMessage>
) : error ? (
<StateMessage theme={theme}>{error}</StateMessage>
) : !forecast ? (
<StateMessage theme={theme}>Sem previsão meteorológica disponível.</StateMessage>
) : (
<div className="space-y-4">
<TodayForecastHero
theme={theme}
forecast={forecast}
/>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{forecast.daily.slice(1, 7).map((day) => (
<DailyForecastTile
key={day.date}
theme={theme}
day={day}
/>
))}
</div>
</div>
)}
</section>
);
}
function TodayForecastHero({
theme,
forecast,
}: {
theme: "dark" | "light";
forecast: WeatherForecastResponse;
}) {
const isDark = theme === "dark";
const today = forecast.daily[0];
return (
<div
className={
isDark
? `${RADIUS} border border-white/10 bg-white/[0.03] p-5`
: `${RADIUS} border border-slate-200 bg-slate-50 p-5`
}
>
<div className="grid gap-4 xl:grid-cols-[320px_1fr]">
<div className="flex items-center gap-4">
{today.condition?.icon && (
<img
src={today.condition.icon}
alt={today.condition.text}
className="h-16 w-16 shrink-0 opacity-90"
/>
)}
<div>
<p
className={
isDark
? "text-xs font-bold uppercase tracking-[0.16em] text-slate-500"
: "text-xs font-bold uppercase tracking-[0.16em] text-slate-400"
}
>
Hoje
</p>
<p
className={
isDark
? "mt-1 text-sm font-semibold text-slate-400"
: "mt-1 text-sm font-semibold text-slate-500"
}
>
{formatDay(today.date)}
</p>
<div
className={
isDark
? "mt-2 text-[46px] font-black leading-none tracking-[-0.06em] text-slate-100"
: "mt-2 text-[46px] font-black leading-none tracking-[-0.06em] text-slate-950"
}
>
{Math.round(today.maxTemperatureC)}°
<span className="ml-1 text-lg tracking-normal text-slate-500">
/ {Math.round(today.minTemperatureC)}°
</span>
</div>
<p
className={
isDark
? "mt-2 text-sm font-semibold text-slate-300"
: "mt-2 text-sm font-semibold text-slate-600"
}
>
{today.condition?.text ?? "--"}
</p>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
<WeatherMiniStat
theme={theme}
icon={CloudRain}
label="Prob. chuva"
value={`${today.dailyRainChance}%`}
/>
<WeatherMiniStat
theme={theme}
icon={Sun}
label="UV"
value={today.uv.toFixed(0)}
/>
<WeatherMiniStat
theme={theme}
icon={Wind}
label="Vento"
value={`${forecast.current.windKph.toFixed(1)} km/h`}
/>
<WeatherMiniStat
theme={theme}
icon={Droplets}
label="Humidade"
value={`${forecast.current.humidity}%`}
/>
</div>
</div>
</div>
);
}
function DailyForecastTile({
theme,
day,
}: {
theme: "dark" | "light";
day: WeatherForecastResponse["daily"][number];
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} border border-white/10 bg-white/[0.03] p-3`
: `${RADIUS} border border-slate-200 bg-slate-50 p-3`
}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p
className={
isDark
? "text-sm font-black text-slate-100"
: "text-sm font-black text-slate-950"
}
>
{formatDay(day.date)}
</p>
<p
className={
isDark
? "mt-1 truncate text-xs text-slate-400"
: "mt-1 truncate text-xs text-slate-500"
}
>
{day.condition?.text ?? "--"}
</p>
</div>
{day.condition?.icon && (
<img
src={day.condition.icon}
alt={day.condition.text}
className="h-9 w-9 shrink-0 opacity-90"
/>
)}
</div>
<div className="mt-4">
<p
className={
isDark
? "text-2xl font-black text-slate-100"
: "text-2xl font-black text-slate-950"
}
>
{Math.round(day.maxTemperatureC)}°
<span className="ml-1 text-sm font-bold text-slate-500">
/ {Math.round(day.minTemperatureC)}°
</span>
</p>
</div>
<div className="mt-4 flex items-center justify-between gap-2 text-xs">
<span
className={
isDark
? "flex items-center gap-1 font-semibold text-slate-400"
: "flex items-center gap-1 font-semibold text-slate-500"
}
>
<CloudRain className="h-3.5 w-3.5" />
{day.dailyRainChance}%
</span>
<span
className={
isDark
? "flex items-center gap-1 font-semibold text-slate-400"
: "flex items-center gap-1 font-semibold text-slate-500"
}
>
<Sun className="h-3.5 w-3.5" />
UV {day.uv.toFixed(0)}
</span>
</div>
</div>
);
}
function WeatherMiniStat({
theme,
icon: Icon,
label,
value,
}: {
theme: "dark" | "light";
icon: LucideIcon;
label: string;
value: string;
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} border border-white/10 bg-[#0b1220] px-3 py-2.5`
: `${RADIUS} border border-slate-200 bg-white px-3 py-2.5`
}
>
<Icon
className={
isDark
? "mb-1.5 h-4 w-4 text-slate-400"
: "mb-1.5 h-4 w-4 text-slate-500"
}
/>
<p className="text-[11px] text-slate-500">{label}</p>
<p
className={
isDark
? "mt-0.5 text-sm font-black text-slate-100"
: "mt-0.5 text-sm font-black text-slate-950"
}
>
{value}
</p>
</div>
);
}
function StateMessage({
theme,
children,
}: {
theme: "dark" | "light";
children: string;
}) {
const isDark = theme === "dark";
return (
<div
className={
isDark
? `${RADIUS} border border-white/10 bg-white/[0.03] p-5 text-sm text-slate-400`
: `${RADIUS} border border-slate-200 bg-slate-50 p-5 text-sm text-slate-500`
}
>
{children}
</div>
);
}
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 (
<article
className={
isDark
? "absolute right-0 top-10 w-[340px] rounded-[8px] border border-white/10 bg-[#07111f]/92 p-7 shadow-[0_22px_60px_rgba(0,0,0,0.42)] backdrop-blur-xl"
: "absolute right-0 top-10 w-[340px] rounded-[8px] border border-slate-200/80 bg-white/96 p-7 shadow-[0_14px_40px_rgba(15,23,42,0.10)] backdrop-blur-xl"
}
>
<p className="flex items-center gap-2 text-sm font-bold">
<MapPin className="h-4 w-4" />
{forecast
? `${forecast.location.name}, ${forecast.location.country}`
: "Meteorologia"}
</p>
{loading ? (
<div className="mt-5 text-sm text-slate-400">
A carregar previsão...
</div>
) : error ? (
<div className="mt-5 text-sm text-red-400">
{error}
</div>
) : today ? (
<>
<div className="mt-6 flex items-start justify-between gap-5">
<div>
<p
className={
isDark
? "text-xs font-bold uppercase tracking-[0.18em] text-slate-400"
: "text-xs font-bold uppercase tracking-[0.18em] text-slate-500"
}
>
Hoje
</p>
<p
className={
isDark
? "mt-1 text-sm font-semibold text-slate-300"
: "mt-1 text-sm font-semibold text-slate-600"
}
>
{formatDay(today.date)}
</p>
<div className="mt-5 flex items-end">
<span className="text-[44px] font-black leading-none tracking-[-0.06em]">
{Math.round(today.maxTemperatureC)}°
</span>
<span className="mb-1 ml-2 text-base font-bold text-slate-500">
/ {Math.round(today.minTemperatureC)}°
</span>
</div>
<p
className={
isDark
? "mt-2 text-sm text-slate-300"
: "mt-2 text-sm text-slate-700"
}
>
{today.condition?.text ?? "--"}
</p>
<div
className={
isDark
? "mt-4 flex flex-wrap gap-2 text-xs text-slate-400"
: "mt-4 flex flex-wrap gap-2 text-xs text-slate-600"
}
>
<span
className={
isDark
? "rounded-full border border-white/10 bg-white/5 px-2 py-1"
: "rounded-full border border-slate-200 bg-slate-100 px-2 py-1"
}
>
Chuva {today.dailyRainChance}%
</span>
<span
className={
isDark
? "rounded-full border border-white/10 bg-white/5 px-2 py-1"
: "rounded-full border border-slate-200 bg-slate-100 px-2 py-1"
}
>
UV {today.uv.toFixed(0)}
</span>
</div>
</div>
{today.condition?.icon ? (
<img
src={today.condition.icon}
alt={today.condition.text}
className="h-16 w-16 shrink-0 opacity-90"
/>
) : (
<Cloud
className={
isDark
? "h-16 w-16 shrink-0 text-slate-200"
: "h-16 w-16 shrink-0 text-slate-400"
}
strokeWidth={1.8}
/>
)}
</div>
<div
className={
isDark
? "mt-6 border-t border-white/10 pt-4"
: "mt-6 border-t border-slate-200 pt-4"
}
>
<button
type="button"
onClick={onOpenMeteo}
className="flex items-center gap-3 text-sm text-slate-500 transition hover:text-emerald-400"
>
Ver meteorologia
<ArrowRight className="h-4 w-4 text-emerald-400" />
</button>
</div>
</>
) : (
<div className="mt-5 text-sm text-slate-400">
Sem previsão diária disponível.
</div>
)}
</article>
);
}
@@ -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<WeatherForecastResponse | null>(null);
const [location, setLocation] = useState<LocationState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}
File diff suppressed because it is too large Load Diff