mirror of
https://github.com/furyhawk/home_stack.git
synced 2026-07-21 02:06:47 +00:00
feat: implement Fury Dashboard with sensor data visualization and API integration
This commit is contained in:
@@ -111,6 +111,14 @@ VITE_API_URL=https://api.my-domain.example.com
|
||||
|
||||
Then, when you run the frontend, it will use that URL as the base URL for the API.
|
||||
|
||||
The home dashboard also reads public sensor data from `https://api.furyhawk.lol`. Those requests are routed through the frontend at `/fury-api` in both Vite dev mode and nginx so the browser does not hit the remote origin directly.
|
||||
|
||||
Example proxied route:
|
||||
|
||||
```text
|
||||
/fury-api/temperature/search?limit=12
|
||||
```
|
||||
|
||||
## Code Structure
|
||||
|
||||
The frontend code is structured as follows:
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location /fury-api/ {
|
||||
proxy_pass https://api.furyhawk.lol/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host api.furyhawk.lol;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Flex,
|
||||
Heading,
|
||||
HStack,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Table,
|
||||
Text,
|
||||
VStack,
|
||||
} from "@chakra-ui/react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
|
||||
import {
|
||||
type FuryDashboardData,
|
||||
type FuryMetricKey,
|
||||
fetchFuryDashboard,
|
||||
} from "./furyDashboardApi"
|
||||
import SensorTrendChart from "./SensorTrendChart"
|
||||
|
||||
const metricAccent: Record<FuryMetricKey, string> = {
|
||||
humidity: "teal.500",
|
||||
pressure: "orange.500",
|
||||
temperature: "red.500",
|
||||
}
|
||||
|
||||
const formatRelativeTime = (isoDate: string) => {
|
||||
const deltaInMinutes = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - new Date(isoDate).getTime()) / 60_000),
|
||||
)
|
||||
|
||||
if (deltaInMinutes < 1) {
|
||||
return "just now"
|
||||
}
|
||||
|
||||
if (deltaInMinutes < 60) {
|
||||
return `${deltaInMinutes} min ago`
|
||||
}
|
||||
|
||||
const hours = Math.floor(deltaInMinutes / 60)
|
||||
const minutes = deltaInMinutes % 60
|
||||
|
||||
if (hours < 24) {
|
||||
return minutes === 0 ? `${hours} hr ago` : `${hours} hr ${minutes} min ago`
|
||||
}
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} day${days === 1 ? "" : "s"} ago`
|
||||
}
|
||||
|
||||
const formatTimestamp = (isoDate: string) => {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
month: "short",
|
||||
}).format(new Date(isoDate))
|
||||
}
|
||||
|
||||
const formatValue = (value: number | null, unit: string) => {
|
||||
if (value === null || Number.isNaN(value)) {
|
||||
return "--"
|
||||
}
|
||||
|
||||
const digits = unit === "hPa" ? 2 : 1
|
||||
return `${value.toFixed(digits)}${unit}`
|
||||
}
|
||||
|
||||
const formatDelta = (current: number | undefined, previous: number | undefined, unit: string) => {
|
||||
if (current === undefined || previous === undefined) {
|
||||
return "No previous sample"
|
||||
}
|
||||
|
||||
const delta = current - previous
|
||||
const sign = delta > 0 ? "+" : ""
|
||||
const digits = unit === "hPa" ? 2 : 1
|
||||
|
||||
if (delta === 0) {
|
||||
return `Unchanged from last sample`
|
||||
}
|
||||
|
||||
return `${sign}${delta.toFixed(digits)}${unit} vs last sample`
|
||||
}
|
||||
|
||||
const buildComfortSummary = (dashboard: FuryDashboardData) => {
|
||||
const temperature = dashboard.metrics.temperature.latest?.value
|
||||
const humidity = dashboard.metrics.humidity.latest?.value
|
||||
const pressure = dashboard.metrics.pressure.latest?.value
|
||||
|
||||
if (
|
||||
temperature === undefined ||
|
||||
humidity === undefined ||
|
||||
pressure === undefined
|
||||
) {
|
||||
return "Waiting for enough sensor data to describe the current room state."
|
||||
}
|
||||
|
||||
if (temperature >= 30 || humidity >= 75) {
|
||||
return "The room is running warm and humid right now. Ventilation or cooling would help."
|
||||
}
|
||||
|
||||
if (temperature <= 24 && humidity <= 60) {
|
||||
return "Conditions look cool and dry."
|
||||
}
|
||||
|
||||
if (pressure >= 1008) {
|
||||
return "Pressure is relatively steady while the room stays within a typical comfort band."
|
||||
}
|
||||
|
||||
return "Conditions are stable, with moderate temperature and humidity."
|
||||
}
|
||||
|
||||
function DashboardLoading() {
|
||||
return (
|
||||
<Container maxW="full" py={8}>
|
||||
<VStack align="stretch" gap={6}>
|
||||
<Card.Root>
|
||||
<Card.Body gap={4}>
|
||||
<Skeleton height="8" width="20rem" />
|
||||
<Skeleton height="4" width="28rem" />
|
||||
<Skeleton height="4" width="16rem" />
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
<SimpleGrid columns={{ base: 1, lg: 3 }} gap={4}>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card.Root key={`metric-skeleton-${index}`}>
|
||||
<Card.Body gap={3}>
|
||||
<Skeleton height="4" width="8rem" />
|
||||
<Skeleton height="10" width="10rem" />
|
||||
<Skeleton height="4" width="12rem" />
|
||||
<Skeleton height="24" />
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
function FuryDashboard() {
|
||||
const { user: currentUser } = useAuth()
|
||||
const { data, error, isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ["fury-dashboard"],
|
||||
queryFn: fetchFuryDashboard,
|
||||
refetchInterval: 60_000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardLoading />
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<Container maxW="full" py={8}>
|
||||
<Card.Root>
|
||||
<Card.Body gap={4}>
|
||||
<Heading size="lg">Environment Dashboard</Heading>
|
||||
<Text color="red.500">
|
||||
Sensor data could not be loaded from the Furyhawk API proxy.
|
||||
</Text>
|
||||
<Button alignSelf="flex-start" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const latestSampleTimes = Object.values(data.metrics)
|
||||
.map((metric) => metric.latest?.updateTime)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort((left, right) =>
|
||||
new Date(right).getTime() - new Date(left).getTime(),
|
||||
)
|
||||
const newestSampleTime = latestSampleTimes[0] ?? data.generatedAt
|
||||
const welcomeName = currentUser?.full_name || currentUser?.email || "there"
|
||||
|
||||
return (
|
||||
<Container maxW="full" py={8}>
|
||||
<VStack align="stretch" gap={6}>
|
||||
<Card.Root overflow="hidden">
|
||||
<Card.Body
|
||||
bgGradient="linear(to-r, gray.900, gray.700)"
|
||||
color="white"
|
||||
gap={4}
|
||||
p={{ base: 5, md: 7 }}
|
||||
>
|
||||
<Flex
|
||||
align={{ base: "flex-start", md: "center" }}
|
||||
direction={{ base: "column", md: "row" }}
|
||||
gap={4}
|
||||
justify="space-between"
|
||||
>
|
||||
<Box>
|
||||
<Text fontSize="sm" textTransform="uppercase" opacity={0.8}>
|
||||
Hi, {welcomeName}
|
||||
</Text>
|
||||
<Heading size="xl" mt={1}>
|
||||
Environment Dashboard
|
||||
</Heading>
|
||||
<Text mt={2} maxW="3xl" opacity={0.85}>
|
||||
Live temperature, humidity, and pressure samples proxied from
|
||||
api.furyhawk.lol.
|
||||
</Text>
|
||||
</Box>
|
||||
<HStack flexWrap="wrap" gap={3}>
|
||||
<Badge colorScheme="green">60s refresh</Badge>
|
||||
<Badge colorScheme="blue">
|
||||
Latest sample {formatRelativeTime(newestSampleTime)}
|
||||
</Badge>
|
||||
{isFetching ? <Badge colorScheme="orange">Refreshing</Badge> : null}
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
<Text maxW="4xl">{buildComfortSummary(data)}</Text>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, lg: 3 }} gap={4}>
|
||||
{Object.values(data.metrics).map((metric) => (
|
||||
<Card.Root key={metric.key}>
|
||||
<Card.Body gap={5}>
|
||||
<Flex justify="space-between" gap={4}>
|
||||
<Box>
|
||||
<Text color="gray.500" fontSize="sm" textTransform="uppercase">
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Heading size="2xl" mt={2} color={metricAccent[metric.key]}>
|
||||
{metric.latest?.rawValue ?? "--"}
|
||||
</Heading>
|
||||
</Box>
|
||||
<Badge alignSelf="flex-start" colorScheme="gray">
|
||||
{metric.summary.count} pts / 24h
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{metric.description}
|
||||
</Text>
|
||||
<Text fontSize="sm" mt={1}>
|
||||
{formatDelta(
|
||||
metric.latest?.value,
|
||||
metric.previous?.value,
|
||||
metric.unit,
|
||||
)}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.500" mt={1}>
|
||||
Updated {metric.latest ? formatRelativeTime(metric.latest.updateTime) : "--"}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<SimpleGrid columns={3} gap={3}>
|
||||
<Box bg="gray.50" borderRadius="md" p={3}>
|
||||
<Text color="gray.500" fontSize="xs" textTransform="uppercase">
|
||||
Average
|
||||
</Text>
|
||||
<Text fontWeight="semibold" mt={1}>
|
||||
{formatValue(metric.summary.average, metric.unit)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box bg="gray.50" borderRadius="md" p={3}>
|
||||
<Text color="gray.500" fontSize="xs" textTransform="uppercase">
|
||||
Low
|
||||
</Text>
|
||||
<Text fontWeight="semibold" mt={1}>
|
||||
{formatValue(metric.summary.min, metric.unit)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box bg="gray.50" borderRadius="md" p={3}>
|
||||
<Text color="gray.500" fontSize="xs" textTransform="uppercase">
|
||||
High
|
||||
</Text>
|
||||
<Text fontWeight="semibold" mt={1}>
|
||||
{formatValue(metric.summary.max, metric.unit)}
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<SensorTrendChart
|
||||
color={metricAccent[metric.key]}
|
||||
readings={metric.readings}
|
||||
title={`${metric.label} Trend`}
|
||||
unit={metric.unit}
|
||||
/>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Body gap={4}>
|
||||
<Flex
|
||||
align={{ base: "flex-start", md: "center" }}
|
||||
direction={{ base: "column", md: "row" }}
|
||||
gap={3}
|
||||
justify="space-between"
|
||||
>
|
||||
<Box>
|
||||
<Heading size="md">Recent Samples</Heading>
|
||||
<Text color="gray.500" mt={1}>
|
||||
Latest synchronized sensor snapshots from the external telemetry API.
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
Last fetch {formatTimestamp(data.generatedAt)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Table.Root size={{ base: "sm", md: "md" }}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader w="14rem">Timestamp</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Temperature</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Humidity</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Pressure</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{data.recentSamples.map((sample) => (
|
||||
<Table.Row key={sample.updateTime}>
|
||||
<Table.Cell>{formatTimestamp(sample.updateTime)}</Table.Cell>
|
||||
<Table.Cell>{sample.temperature ?? "--"}</Table.Cell>
|
||||
<Table.Cell>{sample.humidity ?? "--"}</Table.Cell>
|
||||
<Table.Cell>{sample.pressure ?? "--"}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
</VStack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default FuryDashboard
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Box, Flex, Text } from "@chakra-ui/react"
|
||||
|
||||
import type { FuryReading } from "./furyDashboardApi"
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||
const CHART_WIDTH = 640
|
||||
const CHART_HEIGHT = 220
|
||||
const CHART_PADDING_X = 18
|
||||
const CHART_PADDING_Y = 18
|
||||
const MAX_POINTS = 72
|
||||
|
||||
interface SensorTrendChartProps {
|
||||
color: string
|
||||
readings: FuryReading[]
|
||||
title: string
|
||||
unit: string
|
||||
}
|
||||
|
||||
const formatAxisValue = (value: number, unit: string) => {
|
||||
const digits = unit === "hPa" ? 1 : 0
|
||||
return `${value.toFixed(digits)}${unit}`
|
||||
}
|
||||
|
||||
const formatTimeLabel = (isoDate: string) => {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(isoDate))
|
||||
}
|
||||
|
||||
const getLastDayReadings = (readings: FuryReading[]) => {
|
||||
const since = Date.now() - DAY_IN_MS
|
||||
|
||||
return readings
|
||||
.filter((reading) => new Date(reading.updateTime).getTime() >= since)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(left.updateTime).getTime() - new Date(right.updateTime).getTime(),
|
||||
)
|
||||
}
|
||||
|
||||
const downsampleReadings = (readings: FuryReading[]) => {
|
||||
if (readings.length <= MAX_POINTS) {
|
||||
return readings
|
||||
}
|
||||
|
||||
const step = Math.ceil(readings.length / MAX_POINTS)
|
||||
const sampled = readings.filter((_, index) => index % step === 0)
|
||||
const lastReading = readings[readings.length - 1]
|
||||
|
||||
if (sampled[sampled.length - 1]?.id !== lastReading?.id && lastReading) {
|
||||
sampled.push(lastReading)
|
||||
}
|
||||
|
||||
return sampled
|
||||
}
|
||||
|
||||
function SensorTrendChart({ color, readings, title, unit }: SensorTrendChartProps) {
|
||||
const dayReadings = downsampleReadings(getLastDayReadings(readings))
|
||||
|
||||
if (dayReadings.length < 2) {
|
||||
return (
|
||||
<Box
|
||||
bg="gray.50"
|
||||
borderRadius="lg"
|
||||
minH="220px"
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderColor="gray.100"
|
||||
>
|
||||
<Text fontWeight="medium">{title}</Text>
|
||||
<Text color="gray.500" fontSize="sm" mt={2}>
|
||||
Not enough samples yet to draw a 24-hour chart.
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const values = dayReadings.map((reading) => reading.value)
|
||||
const minValue = Math.min(...values)
|
||||
const maxValue = Math.max(...values)
|
||||
const valueRange = maxValue - minValue || 1
|
||||
const innerWidth = CHART_WIDTH - CHART_PADDING_X * 2
|
||||
const innerHeight = CHART_HEIGHT - CHART_PADDING_Y * 2
|
||||
|
||||
const points = dayReadings.map((reading, index) => {
|
||||
const x =
|
||||
CHART_PADDING_X +
|
||||
(index / Math.max(dayReadings.length - 1, 1)) * innerWidth
|
||||
const normalizedValue = (reading.value - minValue) / valueRange
|
||||
const y = CHART_HEIGHT - CHART_PADDING_Y - normalizedValue * innerHeight
|
||||
|
||||
return {
|
||||
reading,
|
||||
x,
|
||||
y,
|
||||
}
|
||||
})
|
||||
|
||||
const linePoints = points.map((point) => `${point.x},${point.y}`).join(" ")
|
||||
const areaPath = [
|
||||
`M ${points[0]?.x} ${CHART_HEIGHT - CHART_PADDING_Y}`,
|
||||
...points.map((point) => `L ${point.x} ${point.y}`),
|
||||
`L ${points[points.length - 1]?.x} ${CHART_HEIGHT - CHART_PADDING_Y}`,
|
||||
"Z",
|
||||
].join(" ")
|
||||
const midIndex = Math.floor(points.length / 2)
|
||||
const axisLevels = [maxValue, (maxValue + minValue) / 2, minValue]
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="space-between" align="flex-start" gap={3} mb={3}>
|
||||
<Box>
|
||||
<Text fontWeight="medium">{title}</Text>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
Last 24 hours, sampled every few minutes
|
||||
</Text>
|
||||
</Box>
|
||||
<Box textAlign="right">
|
||||
<Text fontSize="xs" color="gray.500" textTransform="uppercase">
|
||||
Range
|
||||
</Text>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{formatAxisValue(minValue, unit)} to {formatAxisValue(maxValue, unit)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Box
|
||||
bg="gray.50"
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.100"
|
||||
overflow="hidden"
|
||||
p={3}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
||||
width="100%"
|
||||
height="220"
|
||||
role="img"
|
||||
aria-label={`${title} trend for the last 24 hours`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`trend-fill-${title}`} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.03" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{axisLevels.map((level) => {
|
||||
const normalizedValue = (level - minValue) / valueRange
|
||||
const y = CHART_HEIGHT - CHART_PADDING_Y - normalizedValue * innerHeight
|
||||
|
||||
return (
|
||||
<g key={`${title}-${level}`}>
|
||||
<line
|
||||
x1={CHART_PADDING_X}
|
||||
x2={CHART_WIDTH - CHART_PADDING_X}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="rgba(148, 163, 184, 0.28)"
|
||||
strokeDasharray="4 6"
|
||||
/>
|
||||
<text
|
||||
x={CHART_WIDTH - CHART_PADDING_X}
|
||||
y={y - 6}
|
||||
textAnchor="end"
|
||||
fill="#64748b"
|
||||
fontSize="12"
|
||||
>
|
||||
{formatAxisValue(level, unit)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
<path d={areaPath} fill={`url(#trend-fill-${title})`} />
|
||||
<polyline
|
||||
fill="none"
|
||||
points={linePoints}
|
||||
stroke={color}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
|
||||
{points.map((point, index) => {
|
||||
const isEdge = index === 0 || index === midIndex || index === points.length - 1
|
||||
|
||||
if (!isEdge) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={`${point.reading.id}-${index}`}>
|
||||
<circle cx={point.x} cy={point.y} fill={color} r="4" />
|
||||
<circle cx={point.x} cy={point.y} fill="white" r="2" />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<Flex justify="space-between" mt={2} color="gray.500" fontSize="xs">
|
||||
<Text>{formatTimeLabel(dayReadings[0].updateTime)}</Text>
|
||||
<Text>{formatTimeLabel(dayReadings[midIndex].updateTime)}</Text>
|
||||
<Text>{formatTimeLabel(dayReadings[dayReadings.length - 1].updateTime)}</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default SensorTrendChart
|
||||
@@ -0,0 +1,196 @@
|
||||
import axios from "axios"
|
||||
|
||||
const furyApi = axios.create({
|
||||
baseURL: "/fury-api",
|
||||
timeout: 15_000,
|
||||
})
|
||||
|
||||
const SENSOR_LIMIT = 288
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const metricConfig = {
|
||||
humidity: {
|
||||
description: "Indoor moisture trend",
|
||||
field: "humidity",
|
||||
label: "Humidity",
|
||||
path: "/humidity/search",
|
||||
unit: "%",
|
||||
},
|
||||
pressure: {
|
||||
description: "Barometric pressure trend",
|
||||
field: "pressure",
|
||||
label: "Pressure",
|
||||
path: "/pressure/search",
|
||||
unit: "hPa",
|
||||
},
|
||||
temperature: {
|
||||
description: "Ambient temperature trend",
|
||||
field: "temperature",
|
||||
label: "Temperature",
|
||||
path: "/temperature/search",
|
||||
unit: "C",
|
||||
},
|
||||
} as const
|
||||
|
||||
export type FuryMetricKey = keyof typeof metricConfig
|
||||
|
||||
type MetricField = (typeof metricConfig)[FuryMetricKey]["field"]
|
||||
|
||||
interface FuryApiItem {
|
||||
id: number
|
||||
humidity?: string
|
||||
pressure?: string
|
||||
temperature?: string
|
||||
update_time: string
|
||||
}
|
||||
|
||||
export interface FuryReading {
|
||||
id: number
|
||||
rawValue: string
|
||||
updateTime: string
|
||||
value: number
|
||||
}
|
||||
|
||||
interface FurySummary {
|
||||
average: number | null
|
||||
count: number
|
||||
max: number | null
|
||||
min: number | null
|
||||
}
|
||||
|
||||
export interface FuryMetricSnapshot {
|
||||
description: string
|
||||
key: FuryMetricKey
|
||||
label: string
|
||||
latest: FuryReading | null
|
||||
previous: FuryReading | null
|
||||
readings: FuryReading[]
|
||||
summary: FurySummary
|
||||
unit: string
|
||||
}
|
||||
|
||||
export interface FuryRecentSample {
|
||||
humidity: string | null
|
||||
pressure: string | null
|
||||
temperature: string | null
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
export interface FuryDashboardData {
|
||||
generatedAt: string
|
||||
metrics: Record<FuryMetricKey, FuryMetricSnapshot>
|
||||
recentSamples: FuryRecentSample[]
|
||||
}
|
||||
|
||||
const parseMeasurement = (rawValue: string) => {
|
||||
const match = rawValue.match(/-?\d+(?:\.\d+)?/)
|
||||
return match ? Number(match[0]) : Number.NaN
|
||||
}
|
||||
|
||||
const calculateSummary = (readings: FuryReading[]) => {
|
||||
const since = Date.now() - DAY_IN_MS
|
||||
const recentValues = readings
|
||||
.filter((reading) => new Date(reading.updateTime).getTime() >= since)
|
||||
.map((reading) => reading.value)
|
||||
.filter((value) => Number.isFinite(value))
|
||||
|
||||
if (recentValues.length === 0) {
|
||||
return {
|
||||
average: null,
|
||||
count: 0,
|
||||
max: null,
|
||||
min: null,
|
||||
}
|
||||
}
|
||||
|
||||
const total = recentValues.reduce((sum, value) => sum + value, 0)
|
||||
|
||||
return {
|
||||
average: total / recentValues.length,
|
||||
count: recentValues.length,
|
||||
max: Math.max(...recentValues),
|
||||
min: Math.min(...recentValues),
|
||||
}
|
||||
}
|
||||
|
||||
const toReadings = (items: FuryApiItem[], field: MetricField) => {
|
||||
return items
|
||||
.map((item) => {
|
||||
const rawValue = item[field]
|
||||
|
||||
if (!rawValue) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
rawValue,
|
||||
updateTime: item.update_time,
|
||||
value: parseMeasurement(rawValue),
|
||||
}
|
||||
})
|
||||
.filter((item): item is FuryReading => item !== null)
|
||||
}
|
||||
|
||||
const fetchMetric = async <TMetricKey extends FuryMetricKey>(
|
||||
metric: TMetricKey,
|
||||
limit = SENSOR_LIMIT,
|
||||
): Promise<FuryMetricSnapshot> => {
|
||||
const config = metricConfig[metric]
|
||||
const response = await furyApi.get<FuryApiItem[]>(config.path, {
|
||||
params: { limit },
|
||||
})
|
||||
const readings = toReadings(response.data, config.field)
|
||||
|
||||
return {
|
||||
description: config.description,
|
||||
key: metric,
|
||||
label: config.label,
|
||||
latest: readings[0] ?? null,
|
||||
previous: readings[1] ?? null,
|
||||
readings,
|
||||
summary: calculateSummary(readings),
|
||||
unit: config.unit,
|
||||
}
|
||||
}
|
||||
|
||||
const buildRecentSamples = (
|
||||
temperature: FuryReading[],
|
||||
humidity: FuryReading[],
|
||||
pressure: FuryReading[],
|
||||
) => {
|
||||
const sampleCount = Math.min(temperature.length, humidity.length, pressure.length, 8)
|
||||
|
||||
return Array.from({ length: sampleCount }, (_, index) => ({
|
||||
humidity: humidity[index]?.rawValue ?? null,
|
||||
pressure: pressure[index]?.rawValue ?? null,
|
||||
temperature: temperature[index]?.rawValue ?? null,
|
||||
updateTime:
|
||||
temperature[index]?.updateTime ||
|
||||
humidity[index]?.updateTime ||
|
||||
pressure[index]?.updateTime ||
|
||||
new Date().toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
export const fetchFuryDashboard = async (): Promise<FuryDashboardData> => {
|
||||
const [temperature, humidity, pressure] = await Promise.all([
|
||||
fetchMetric("temperature"),
|
||||
fetchMetric("humidity"),
|
||||
fetchMetric("pressure"),
|
||||
])
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
metrics: {
|
||||
humidity,
|
||||
pressure,
|
||||
temperature,
|
||||
},
|
||||
recentSamples: buildRecentSamples(
|
||||
temperature.readings,
|
||||
humidity.readings,
|
||||
pressure.readings,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,11 @@
|
||||
import { Box, Container, Text } from "@chakra-ui/react"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
|
||||
import useAuth from "@/hooks/useAuth"
|
||||
import FuryDashboard from "@/components/dashboard/FuryDashboard"
|
||||
|
||||
export const Route = createFileRoute("/_layout/")({
|
||||
component: Dashboard,
|
||||
})
|
||||
|
||||
function Dashboard() {
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="full">
|
||||
<Box pt={12} m={4}>
|
||||
<Text fontSize="2xl" truncate maxW="sm">
|
||||
Hi, {currentUser?.full_name || currentUser?.email} 👋🏼
|
||||
</Text>
|
||||
<Text>Welcome back, nice to see you again!</Text>
|
||||
</Box>
|
||||
</Container>
|
||||
</>
|
||||
)
|
||||
return <FuryDashboard />
|
||||
}
|
||||
|
||||
@@ -22,5 +22,13 @@ export default defineConfig({
|
||||
"localhost:5175",
|
||||
"dev.furyhawk.lol",
|
||||
],
|
||||
proxy: {
|
||||
"/fury-api": {
|
||||
target: "https://api.furyhawk.lol",
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (path) => path.replace(/^\/fury-api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user