diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 42f5c59..38e7dca 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -12,7 +12,7 @@ "app": { "windows": [ { - "title": "litoralregas-frontend", + "title": "Litoral Central", "width": 800, "height": 600 } diff --git a/src/app/App.tsx b/src/app/App.tsx index c5647b3..7313d72 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -14,7 +14,10 @@ function App() { activePage === "meteo" ? ( ) : ( - + setActivePage("meteo")} + /> ) } diff --git a/src/assets/background.png b/src/assets/background.png new file mode 100644 index 0000000..afb8d60 Binary files /dev/null and b/src/assets/background.png differ diff --git a/src/assets/farm-draw.png b/src/assets/farm-draw.png new file mode 100644 index 0000000..34235c6 Binary files /dev/null and b/src/assets/farm-draw.png differ diff --git a/src/assets/farmdraw.png b/src/assets/farmdraw.png new file mode 100644 index 0000000..dff18a8 Binary files /dev/null and b/src/assets/farmdraw.png differ diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 8b52d7b..141c568 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,16 +1,16 @@ -import { ReactNode, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { Sidebar } from "../navigation/Sidebar"; -import { TopBar } from "./TopBar"; -import { BottomStatusBar } from "./BottomStatusBar"; +import { TopBar } from "../layout/TopBar"; import { useTelemetryStream } from "../../features/telemetry/hooks/useTelemetryStream"; import { useNotifications } from "../../features/notifications/hooks/useNotifications"; import { useCurrentUser } from "../../features/auth/hooks/useCurrentUser"; -import { useRuntimeConfig } from "../../features/system/hooks/useRuntimeConfig"; import type { TelemetrySnapshot } from "../../types/telemetry"; import type { AppPage } from "../../app/App"; +type Theme = "dark" | "light"; + type AppShellRenderProps = { - theme: "dark" | "light"; + theme: Theme; snapshots: TelemetrySnapshot[]; }; @@ -19,31 +19,71 @@ type AppShellProps = { onNavigate: (page: AppPage) => void; children: (props: AppShellRenderProps) => ReactNode; }; + +const THEME_STORAGE_KEY = "app-theme"; + export function AppShell({ activePage, onNavigate, children }: AppShellProps) { const telemetry = useTelemetryStream(); const notifications = useNotifications(); const currentUser = useCurrentUser(); - const runtime = useRuntimeConfig(); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [theme, setTheme] = useState<"dark" | "light">("dark"); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [theme, setTheme] = useState(() => { + const stored = localStorage.getItem(THEME_STORAGE_KEY); + return stored === "light" || stored === "dark" ? stored : "dark"; + }); + + const isDark = theme === "dark"; + + useEffect(() => { + localStorage.setItem(THEME_STORAGE_KEY, theme); + }, [theme]); const toggleTheme = () => { setTheme((current) => (current === "dark" ? "light" : "dark")); }; - const isDark = theme === "dark"; - return (
+ +
-
+
- {children({ - theme, - snapshots: telemetry.snapshots, - })} +
+ {children({ + theme, + snapshots: telemetry.snapshots, + })} +
- -
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 14c7996..be167ea 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -25,6 +25,8 @@ type TopBarProps = { onToggleTheme: () => void; }; +const RADIUS = "rounded-[5px]"; + export function TopBar({ connected, lastTimestamp, @@ -40,9 +42,7 @@ export function TopBar({ const isDark = theme === "dark"; const ThemeIcon = isDark ? Moon : Sun; - const systemDate = lastTimestamp - ? new Date(lastTimestamp) - : null; + const systemDate = lastTimestamp ? new Date(lastTimestamp) : null; const formattedTime = systemDate ? systemDate.toLocaleTimeString("pt-PT", { @@ -61,39 +61,35 @@ export function TopBar({ : "--/--/----"; const dropdownClass = isDark - ? "absolute right-0 top-12 z-50 rounded-2xl border border-[#24394A] bg-[#0F1D29] shadow-2xl shadow-black/40" - : "absolute right-0 top-12 z-50 rounded-2xl border border-[#D5DDE6] bg-white shadow-2xl shadow-slate-300/40"; + ? `absolute right-0 top-12 z-50 ${RADIUS} border border-white/[0.04] bg-[#111827] shadow-2xl` + : `absolute right-0 top-12 z-50 ${RADIUS} border border-slate-200 bg-white shadow-xl`; const dropdownTitleClass = isDark - ? "text-sm font-semibold text-[#E4EDF6]" - : "text-sm font-semibold text-[#162434]"; + ? "text-sm font-bold text-slate-100" + : "text-sm font-bold text-slate-950"; - const mutedTextClass = isDark - ? "text-[#8FA3B8]" - : "text-[#607284]"; + const mutedTextClass = "text-slate-500"; const menuItemClass = isDark - ? "flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-[#D4DEE8] transition-colors hover:bg-[#182B3B]" - : "flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left text-sm text-[#162434] transition-colors hover:bg-[#EEF3F7]"; + ? `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-slate-300 transition hover:bg-white/[0.05] hover:text-slate-100` + : `flex w-full items-center gap-3 ${RADIUS} px-3 py-2.5 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-100 hover:text-slate-950`; - const dividerClass = isDark - ? "my-2 h-px bg-[#24394A]" - : "my-2 h-px bg-[#D5DDE6]"; + const dividerClass = isDark ? "my-2 h-px bg-white/10" : "my-2 h-px bg-slate-200"; return (

{pageTitle(activePage)} @@ -103,70 +99,57 @@ export function TopBar({
-
+
- - {connected - ? "Ligado ao sistema" - : "Sistema desligado"} + + {connected ? "Ligado ao sistema" : "Sistema desligado"}
-
- +
+ {formattedTime}
-
- +
+ {formattedDate}
{userMenuOpen && (
-
+
{userInitials}
-
- admin -
+
admin
Administrador @@ -253,22 +231,23 @@ export function TopBar({
- -
- -
- @@ -314,7 +294,7 @@ export function TopBar({ function pageTitle(page: AppPage | null) { switch (page) { case "dashboard": - return "Painel Principal"; + return ""; case "meteo": return "Meteorologia"; diff --git a/src/components/navigation/Sidebar.tsx b/src/components/navigation/Sidebar.tsx index 324f777..c1b46ab 100644 --- a/src/components/navigation/Sidebar.tsx +++ b/src/components/navigation/Sidebar.tsx @@ -1,11 +1,19 @@ +import { useState } from "react"; import { + BarChart3, + ChevronDown, ChevronLeft, ChevronRight, CloudSun, Droplet, + Filter, + Gauge, Home, + Lightbulb, + MonitorDot, Settings, TabletSmartphone, + Waves, Wind, } from "lucide-react"; @@ -20,6 +28,8 @@ type SidebarProps = { onToggleCollapsed: () => void; }; +const RADIUS = "rounded-[10px]"; + const navigationItems: { label: string; page: AppPage; @@ -29,10 +39,24 @@ const navigationItems: { { label: "Meteorologia", page: "meteo", icon: CloudSun }, ]; -const disabledItems = [ +const climateItems = [ + { label: "Iluminação", icon: Lightbulb }, + { label: "Ventilação", icon: Wind }, + { label: "Sinótico", icon: MonitorDot }, + { label: "Gráficos", icon: BarChart3 }, +]; + +const irrigationItems = [ + { label: "Regas", icon: Droplet }, + { label: "Sinótico", icon: MonitorDot }, + { label: "Filtros de Rega", icon: Filter }, + { label: "Consumos", icon: Gauge }, + { label: "Drenagem", icon: Waves }, + { label: "Gráficos", icon: BarChart3 }, +]; + +const utilityItems = [ { label: "Consola (VNC)", icon: TabletSmartphone }, - { label: "Rega", icon: Droplet }, - { label: "Clima", icon: Wind }, { label: "Configurações", icon: Settings }, ]; @@ -45,47 +69,72 @@ export function Sidebar({ }: SidebarProps) { const isDark = theme === "dark"; + const [climateOpen, setClimateOpen] = useState(false); + const [irrigationOpen, setIrrigationOpen] = useState(false); + const [activeTreeItem, setActiveTreeItem] = useState(null); + + const handleTreeClick = (key: string) => { + setActiveTreeItem(key); + }; + + const handleTreeToggle = (section: "climate" | "irrigation") => { + if (collapsed) { + onToggleCollapsed(); + } + + if (section === "climate") { + setClimateOpen((current) => !current); + setIrrigationOpen(false); + return; + } + + setIrrigationOpen((current) => !current); + setClimateOpen(false); + }; + return ( ); +} + +function TreeSection({ + theme, + collapsed, + label, + icon: Icon, + open, + onToggle, + items, + sectionKey, + activeTreeItem, + onItemClick, +}: { + theme: "dark" | "light"; + collapsed: boolean; + label: string; + icon: React.ElementType; + open: boolean; + onToggle: () => void; + items: { label: string; icon: React.ElementType }[]; + sectionKey: string; + activeTreeItem: string | null; + onItemClick: (key: string) => void; +}) { + const isDark = theme === "dark"; + const hasActiveChild = items.some( + (item) => activeTreeItem === `${sectionKey}:${item.label}`, + ); + + return ( +
+ + + {!collapsed && open && ( +
+ {items.map((item) => { + const SubIcon = item.icon; + const key = `${sectionKey}:${item.label}`; + const active = activeTreeItem === key; + + return ( + + ); + })} +
+ )} +
+ ); +} + +function SectionLabel({ + collapsed, + label, +}: { + collapsed: boolean; + label: string; +}) { + if (collapsed) { + return
; + } + + return ( +
+

+ {label} +

+
+ ); +} + +function ActiveIndicator({ isDark }: { isDark: boolean }) { + return ( + + ); +} + +function navButtonClass(isDark: boolean, active: boolean, collapsed: boolean) { + const alignment = collapsed ? "justify-center px-0" : "px-4"; + + if (active) { + return isDark + ? `relative flex w-full items-center gap-3 ${RADIUS} border border-emerald-400/20 bg-[#162033] ${alignment} py-[14px] text-left text-[15px] font-black text-white shadow-[0_12px_28px_rgba(0,0,0,0.22)]` + : `relative flex w-full items-center gap-3 ${RADIUS} border border-[#D8DEE7] bg-white ${alignment} py-[14px] text-left text-[15px] font-black text-[#0F172A] shadow-[0_8px_20px_rgba(15,23,42,0.06)]`; + } + + return isDark + ? `flex w-full items-center gap-3 ${RADIUS} ${alignment} py-[14px] text-left text-[15px] font-semibold text-slate-400 transition hover:bg-white/[0.04] hover:text-white` + : `flex w-full items-center gap-3 ${RADIUS} ${alignment} py-[14px] text-left text-[15px] font-semibold text-slate-600 transition hover:bg-white hover:text-[#0F172A] hover:shadow-sm`; +} + +function navIconClass(isDark: boolean, active: boolean) { + if (active) { + return isDark + ? "h-[22px] w-[22px] shrink-0 text-emerald-300" + : "h-[22px] w-[22px] shrink-0 text-[#0F766E]"; + } + + return isDark + ? "h-[22px] w-[22px] shrink-0 text-slate-500" + : "h-[22px] w-[22px] shrink-0 text-slate-500"; } \ No newline at end of file diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx index a12d438..b1ae68a 100644 --- a/src/features/dashboard/pages/DashboardPage.tsx +++ b/src/features/dashboard/pages/DashboardPage.tsx @@ -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 ( -
-
- +
+ - +
- +
- +
+
+
+

+ Bem-vindo ao +
+ Litoral Central +

- -
+
-
- +

+ 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. +

- + +
- + +
- - +
+ } + iconClass="bg-emerald-500/10 text-emerald-400" + title="Gestão inteligente" + text="Monitorize e controle todos os seus sistemas de forma centralizada." + /> -
- + } + 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." + /> - -
+ } + iconClass="bg-yellow-400/10 text-yellow-300" + title="Decisões informadas" + text="Dados meteorológicos e insights para melhores decisões no campo." + /> +
-
-
-
+
+

+ A Nossa Missão +

+

- Estado Operacional + Soluções inovadoras para +
+ uma{" "} + + agricultura sustentável +

+

- Resumo rápido da instalação. + Tecnologia, conhecimento e proximidade para impulsionar o futuro + do setor agrícola em Portugal.

+ +
+ } + title="Sustentabilidade" + text="Compromisso com o futuro" + /> + } + title="Confiabilidade" + text="Tecnologia robusta e segura" + /> + } + title="Apoio próximo" + text="Sempre ao seu lado" + /> +
- +
+
-
- - - - -
-
- -
-
-

- Próximos Desenvolvimentos -

-

- Espaço preparado para eventos, alarmes, programas e depósitos. -

-
- -
- - - -
-
- +
+ © 2026 Litoral Central. Todos os direitos reservados. + + Feito em Portugal 🇵🇹 + +
+
); } -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 ( -
-
-

- {title} -

- -
- -
-
- -

- {main} -

- -

- {label} -

- -
- {items.map(([key, value]) => ( -
- - {key} - - - {value} - -
- ))} -
-
- ); -} - -type StatusRowProps = { - theme: "dark" | "light"; - label: string; - value: string; - good: boolean; -}; - -function StatusRow({ theme, label, value, good }: StatusRowProps) { - const isDark = theme === "dark"; - - return ( -
- - {label} - - - - {value} - -
- ); -} - -type PlaceholderPanelProps = { + text, +}: { theme: "dark" | "light"; + icon: React.ReactNode; + iconClass: string; title: string; text: string; -}; - -function PlaceholderPanel({ theme, title, text }: PlaceholderPanelProps) { +}) { const isDark = theme === "dark"; return ( -
-

- {title} -

-

- {text} -

+
+
+ {icon} +
+ +
+

{title}

+

+ {text} +

+
+
+ + + + ); +} + +function MissionItem({ + theme, + icon, + title, + text, +}: { + theme: "dark" | "light"; + icon: React.ReactNode; + title: string; + text: string; +}) { + const isDark = theme === "dark"; + + return ( +
+
{icon}
+
+

{title}

+

+ {text} +

+
); -} \ No newline at end of file +} + +function FarmIllustration({ theme }: { theme: "dark" | "light" }) { + const isDark = theme === "dark"; + + return ( +
+ +
+ ); +} + +export default DashboardPage; \ No newline at end of file diff --git a/src/features/meteo/components/AccumulatedHistoryModal.tsx b/src/features/meteo/components/AccumulatedHistoryModal.tsx index 36f1946..9c52e3b 100644 --- a/src/features/meteo/components/AccumulatedHistoryModal.tsx +++ b/src/features/meteo/components/AccumulatedHistoryModal.tsx @@ -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 ( -
+
-

+

Acumulado

-

{title}

+

+ {title} +

-

+

Chave: meteo.{sensor.key}

@@ -89,7 +100,11 @@ export function AccumulatedHistoryModal({ @@ -99,8 +114,8 @@ export function AccumulatedHistoryModal({
@@ -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({
-
+
@@ -155,35 +168,41 @@ export function AccumulatedHistoryModal({ Sem dados acumulados para este período. ) : mode === "chart" ? ( - + [ formatValue(Number(value), unit), @@ -193,16 +212,28 @@ export function AccumulatedHistoryModal({ ) : ( -
+
- + @@ -215,18 +246,31 @@ export function AccumulatedHistoryModal({ {buckets.map((bucket) => ( - - - @@ -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 (
@@ -266,27 +334,47 @@ function StatCard({
-

{label}

-

{value}

+

{label}

+ +

+ {value} +

); } -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}` : ""}`; } diff --git a/src/features/meteo/components/MeteoHistoryModal.tsx b/src/features/meteo/components/MeteoHistoryModal.tsx index ef1d1df..88fb356 100644 --- a/src/features/meteo/components/MeteoHistoryModal.tsx +++ b/src/features/meteo/components/MeteoHistoryModal.tsx @@ -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(); + 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 ( -
+
-

+

Histórico

-

{sensor.name}

-

+ +

+ {sensor.name} +

+ +

Chave: meteo.{sensor.key}

-
- -
+
-
+
{RANGE_OPTIONS.map((range) => ( ))}
)}
+
- - + + {sensor.name} {unit && ` (${unit})`} - Média: {formatValue(stats.average, unit)} - Máx: {formatValue(stats.max, unit)} - Mín: {formatValue(stats.min, unit)} + + Média: {formatValue(stats.average, unit)} + + + Máx: {formatValue(stats.max, unit)} + + + Mín: {formatValue(stats.min, unit)} +
-
+
{loading ? ( A carregar histórico... ) : chartData.length === 0 ? ( @@ -353,32 +388,37 @@ export function MeteoHistoryModal({ - - + + + - + { @@ -387,43 +427,52 @@ export function MeteoHistoryModal({ const value = Number(payload[0].value); return ( -
-

{label}

+
+

+ {label} +

-

+

Atual: {formatValue(value, unit)}

-

+

Média: {formatValue(stats.average, unit)}

-

+

Máx: {formatValue(stats.max, unit)}

-

+

Mín: {formatValue(stats.min, unit)}

); }} /> + {showIndicators && stats.average !== null && ( )} {showCompareLine && stats.current !== null && ( )} @@ -431,14 +480,14 @@ export function MeteoHistoryModal({ {showIndicators && ( -
+
= 0} />
-

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 && ( + {forecast.current.condition.text} + )} +
+ + {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 && ( + {today.condition.text} + )} + +
+

+ 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 && ( + {day.condition.text} + )} +
+ +
+

+ {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 ? ( + {today.condition.text} + ) : ( + + )} +
+ +
+ +
+ + ) : ( +
+ 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 ( - + ); } @@ -786,6 +803,26 @@ function directionName(direction: number | null) { return labels[index]; } +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 getTrend(values?: number[]) { + if (!values || values.length < 2) return "Sem tendência"; + + const first = values[0]; + const last = values[values.length - 1]; + const delta = last - first; + + if (Math.abs(delta) < 0.1) return "Estável"; + if (delta > 0) return "A subir"; + return "A descer"; +} + function temperatureBadge(sensor?: ModuleSensorResponse) { const value = numericValue(sensor); if (value === null) return "Sem dados"; @@ -818,42 +855,74 @@ function radiationBadge(sensor?: ModuleSensorResponse) { return "Baixa"; } -function accentColors(accent: "amber" | "blue" | "cyan" | "emerald") { +function accentColors(accent: Accent, isDark: boolean) { switch (accent) { case "amber": - return { - text: "text-amber-300", - stroke: "stroke-amber-400", - dot: "fill-amber-400", - badge: "border-amber-300/20 bg-amber-400/10", - }; - case "blue": - return { - text: "text-sky-300", - stroke: "stroke-sky-400", - dot: "fill-sky-400", - badge: "border-sky-300/20 bg-sky-400/10", - }; - case "cyan": - return { - text: "text-cyan-300", - stroke: "stroke-cyan-400", - dot: "fill-cyan-400", - badge: "border-cyan-300/20 bg-cyan-400/10", - }; - case "emerald": - return { - text: "text-emerald-300", - stroke: "stroke-emerald-400", - dot: "fill-emerald-400", - badge: "border-emerald-300/20 bg-emerald-400/10", - }; - } -} + return isDark + ? { + icon: "text-amber-200", + stroke: "stroke-amber-200", + dot: "fill-amber-200", + iconBox: "border-white/10 bg-white/[0.03]", + badge: "border-white/10 bg-white/[0.03] text-slate-300", + } + : { + icon: "text-amber-700", + stroke: "stroke-amber-500", + dot: "fill-amber-500", + iconBox: "border-slate-200 bg-slate-50", + badge: "border-slate-200 bg-slate-50 text-slate-600", + }; -function formatTime(timestamp: string) { - return new Date(timestamp).toLocaleTimeString("pt-PT", { - hour: "2-digit", - minute: "2-digit", - }); + case "blue": + return isDark + ? { + icon: "text-sky-200", + stroke: "stroke-sky-200", + dot: "fill-sky-200", + iconBox: "border-white/10 bg-white/[0.03]", + badge: "border-white/10 bg-white/[0.03] text-slate-300", + } + : { + icon: "text-sky-700", + stroke: "stroke-sky-500", + dot: "fill-sky-500", + iconBox: "border-slate-200 bg-slate-50", + badge: "border-slate-200 bg-slate-50 text-slate-600", + }; + + case "cyan": + return isDark + ? { + icon: "text-cyan-200", + stroke: "stroke-cyan-200", + dot: "fill-cyan-200", + iconBox: "border-white/10 bg-white/[0.03]", + badge: "border-white/10 bg-white/[0.03] text-slate-300", + } + : { + icon: "text-cyan-700", + stroke: "stroke-cyan-500", + dot: "fill-cyan-500", + iconBox: "border-slate-200 bg-slate-50", + badge: "border-slate-200 bg-slate-50 text-slate-600", + }; + + case "emerald": + return isDark + ? { + icon: "text-emerald-200", + stroke: "stroke-emerald-200", + dot: "fill-emerald-200", + iconBox: "border-white/10 bg-white/[0.03]", + badge: "border-white/10 bg-white/[0.03] text-slate-300", + } + : { + icon: "text-emerald-700", + stroke: "stroke-emerald-500", + dot: "fill-emerald-500", + iconBox: "border-slate-200 bg-slate-50", + badge: "border-slate-200 bg-slate-50 text-slate-600", + }; + } } \ No newline at end of file diff --git a/src/index.css b/src/index.css index 3dd2255..f32a1e5 100644 --- a/src/index.css +++ b/src/index.css @@ -4,8 +4,14 @@ html, body, #root { margin: 0; - min-height: 100%; - background: #0f1720; + width: 100%; + height: 100%; + overflow: hidden; + background: #0b1220; +} + +* { + box-sizing: border-box; } body { @@ -28,11 +34,19 @@ body { } .custom-scrollbar::-webkit-scrollbar-thumb { - background: linear-gradient(180deg, rgba(56, 189, 248, 0.7), rgba(14, 165, 233, 0.35)); + background: linear-gradient( + 180deg, + rgba(56, 189, 248, 0.7), + rgba(14, 165, 233, 0.35) + ); border: 2px solid rgba(15, 23, 42, 0.9); border-radius: 999px; } .custom-scrollbar::-webkit-scrollbar-thumb:hover { - background: linear-gradient(180deg, rgba(56, 189, 248, 0.95), rgba(14, 165, 233, 0.55)); -} \ No newline at end of file + background: linear-gradient( + 180deg, + rgba(56, 189, 248, 0.95), + rgba(14, 165, 233, 0.55) + ); +} diff --git a/src/types/weather.ts b/src/types/weather.ts new file mode 100644 index 0000000..5cea0e5 --- /dev/null +++ b/src/types/weather.ts @@ -0,0 +1,47 @@ +export type WeatherForecastResponse = { + location: WeatherLocation; + current: WeatherCurrent; + daily: WeatherDaily[]; +}; + +export type WeatherLocation = { + name: string; + region: string; + country: string; + latitude: number; + longitude: number; + localTime: string; +}; + +export type WeatherCondition = { + text: string; + icon: string; + code: number; +}; + +export type WeatherCurrent = { + temperatureC: number; + feelsLikeC: number; + humidity: number; + precipitationMm: number; + windKph: number; + windDegree: number; + windDirection: string; + pressureMb: number; + uv: number; + condition: WeatherCondition; +}; + +export type WeatherDaily = { + date: string; + maxTemperatureC: number; + minTemperatureC: number; + averageTemperatureC: number; + totalPrecipitationMm: number; + dailyRainChance: number; + maxWindKph: number; + uv: number; + sunrise: string; + sunset: string; + condition: WeatherCondition; +}; \ No newline at end of file
Período Início
{bucket.label} + + {formatDate(bucket.from)} + + {formatDate(bucket.to)} + + {formatValue(bucket.total, unit)}