Compare commits

...

3 commits

2 changed files with 358 additions and 135 deletions

View file

@ -128,16 +128,17 @@
import * as Chart from "$lib/components/ui/chart/index.js"; import * as Chart from "$lib/components/ui/chart/index.js";
import * as Card from "$lib/components/ui/card/index.js"; import * as Card from "$lib/components/ui/card/index.js";
import * as Select from "$lib/components/ui/select/index.js"; import * as Select from "$lib/components/ui/select/index.js";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
import { scaleUtc } from "d3-scale";
import { Area, AreaChart, ChartClipPath } from "layerchart";
import { curveNatural } from "d3-shape";
import ChartContainer from "$lib/components/ui/chart/chart-container.svelte";
import { cubicInOut } from "svelte/easing";
import { ChevronDownIcon, Columns2 } from "@lucide/svelte";
let chartData = $state([]); let chartData = $state([]);
let newChartData = $derived(
chartData.filter((item: object) => {
if (item.sensorId === selectedESP) {
return { ...item };
}
}),
);
let timeRange = $state("90d"); let timeRange = $state("90d");
const selectedLabel = $derived.by(() => { const selectedLabel = $derived.by(() => {
@ -154,8 +155,8 @@
}); });
const filteredData = $derived( const filteredData = $derived(
chartData.filter((item) => { newChartData.filter((item) => {
const referenceDate = new Date("2024-06-30"); const now = new Date();
let daysToSubtract = 90; let daysToSubtract = 90;
if (timeRange === "30d") { if (timeRange === "30d") {
daysToSubtract = 30; daysToSubtract = 30;
@ -163,75 +164,132 @@
daysToSubtract = 7; daysToSubtract = 7;
} }
referenceDate.setDate(referenceDate.getDate() - daysToSubtract); const cutoffDate = new Date(now.getTime() - daysToSubtract * 24 * 60 * 60 * 1000);
return item.date >= referenceDate; return item.date >= cutoffDate;
}), }),
); );
const chartConfig = { const chartConfig = {
temperature: { label: "Temperature", color: "var(--chart-1)" }, temperature: { label: "Temperature (°C)", color: "var(--chart-1)" },
humidity: { label: "Humidity", color: "var(--chart-2)" }, humidity: { label: "Humidity (%)", color: "var(--chart-2)" },
altitude: { label: "Altitude", color: "var(--chart-1)" }, altitude: { label: "Altitude (m)", color: "var(--chart-3)" },
pressure: { label: "Pressure", color: "var(--chart-2)" }, pressure: { label: "Pressure (kPa)", color: "var(--chart-4)" },
} satisfies Chart.ChartConfig; } satisfies Chart.ChartConfig;
const table = $state([ let selectedSensor = $state("temperature");
const sensorLabel = $derived.by(() => {
switch (selectedSensor) {
case "altitude":
return "Altitude (m)";
case "humidity":
return "Humidity (%)";
case "pressure":
return "Pressure (kPa)";
case "temperature":
default:
return "Temperature (°C)";
}
});
const sensorOptions = [
{ {
visible: true, key: "temperature",
key: "altitude", label: chartConfig.temperature.label,
label: chartConfig.altitude.label, color: chartConfig.temperature.color,
color: chartConfig.altitude.color,
}, },
{ {
visible: true,
key: "humidity", key: "humidity",
label: chartConfig.humidity.label, label: chartConfig.humidity.label,
color: chartConfig.humidity.color, color: chartConfig.humidity.color,
}, },
{ {
visible: true,
key: "pressure", key: "pressure",
label: chartConfig.pressure.label, label: chartConfig.pressure.label,
color: chartConfig.pressure.color, color: chartConfig.pressure.color,
}, },
{ {
visible: true, key: "altitude",
key: "temperature", label: chartConfig.altitude.label,
label: chartConfig.temperature.label, color: chartConfig.altitude.color,
color: chartConfig.temperature.color,
}, },
]); ];
const tables = $derived( const selectedSensorConfig = $derived(
table sensorOptions.find((s) => s.key === selectedSensor) || sensorOptions[0],
.filter((item) => {
if (item.visible) return true;
return false;
})
.map((item) => {
return { key: item.key, label: item.label, color: item.color };
}),
); );
let onOpenChange = $state(async (open: boolean) => { let esps = $state([]);
if (open) { let selectedESP = $state("Select ESP sensor");
const response = await fetch("/" + data.slug + "/stats");
const number = await response.json();
console.log(number);
chartData = number.map((obj: any) => { let isLoading = $state(false);
return { ...obj, date: new Date(obj.date) }; let hasError = $state(false);
});
let onOpenChange = $state(async (open: boolean) => {
console.log("onOpenChange called with:", open);
if (open) {
console.log("Starting to load statistics...");
isLoading = true;
hasError = false;
try {
console.log(`Fetching stats for floor: ${data.slug}`);
const response = await fetch(`/${data.slug}/stats`);
console.log("Stats response status:", response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const rawData = await response.json();
console.log("Raw stats data:", rawData);
const allesps = rawData.map((i: any) => {
return i.sensorId;
});
esps = allesps.filter((v: any, i: any, a: any) => a.indexOf(v) === i);
if (!rawData || rawData.length === 0) {
console.log("No data available for statistics");
chartData = [];
} else {
chartData = rawData.map((obj: any) => {
return { ...obj, date: new Date(obj.date) };
});
console.log("Processed chart data:", chartData);
}
} catch (error) {
console.error("Error loading statistics:", error);
hasError = true;
chartData = [];
} finally {
isLoading = false;
}
} }
}); });
function exportCSV(): void {
let csv = "data:text/csv;charset=utf-8,";
const headers = "time," + selectedSensorConfig.key;
csv += headers + "\n";
newChartData.forEach((obj: any) => {
csv += obj.date.toISOString() + "," + obj[selectedSensorConfig.key] + "\n";
});
const encodedUri = encodeURI(csv);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", selectedESP + "_" + selectedSensorConfig.key + "_data.csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
</script> </script>
<div class="container mx-auto p-6"> <div class="container mx-auto p-6">
<!-- <div style="display: flex; justify-content: center; align-items: center; height: 100%;"> -->
<h1 class="mb-6 text-center text-4xl font-bold">Floor {data.slug}</h1> <h1 class="mb-6 text-center text-4xl font-bold">Floor {data.slug}</h1>
{#if data.hasConfig && data.floorConfig} {#if data.hasConfig && data.floorConfig}
<!-- Display saved floor plan configuration -->
<div class="relative mx-auto max-w-4xl"> <div class="relative mx-auto max-w-4xl">
{#if data.floorConfig.image} {#if data.floorConfig.image}
<div class="relative overflow-hidden rounded-lg border bg-gray-50"> <div class="relative overflow-hidden rounded-lg border bg-gray-50">
@ -308,7 +366,6 @@
{/if} {/if}
</div> </div>
{:else} {:else}
<!-- Fallback to canvas drawing -->
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<span class="mb-4">{mqttMessage}</span> <span class="mb-4">{mqttMessage}</span>
<canvas bind:this={canvasEl} class="border" width="800" height="600"></canvas> <canvas bind:this={canvasEl} class="border" width="800" height="600"></canvas>
@ -316,7 +373,7 @@
{/if} {/if}
<Dialog.Root bind:onOpenChange> <Dialog.Root bind:onOpenChange>
<Dialog.Trigger class={buttonVariants({ variant: "outline" })}>Statistics</Dialog.Trigger> <Dialog.Trigger class={buttonVariants({ variant: "outline" })}>Statistics</Dialog.Trigger>
<Dialog.Content class="sm:max-h-2/3 sm:max-w-2/3"> <Dialog.Content class="sm:max-h-[90%] sm:max-w-[90%]">
<Dialog.Header> <Dialog.Header>
<div class="grid flex-1 gap-1 text-center sm:text-left"> <div class="grid flex-1 gap-1 text-center sm:text-left">
<Card.Title>Statistics for floor {data.slug}</Card.Title> <Card.Title>Statistics for floor {data.slug}</Card.Title>
@ -325,28 +382,31 @@
</Dialog.Header> </Dialog.Header>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<DropdownMenu.Root> <Select.Root type="single" bind:value={selectedESP}>
<DropdownMenu.Trigger class="rounded-lg sm:ml-auto"> <Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a sensor">
{#snippet child({ props })} {selectedESP}
<Button variant="outline" size="sm" {...props}> </Select.Trigger>
<Columns2 /> <Select.Content class="rounded-xl">
<span class="hidden lg:inline">Customize Data</span> {#each esps as esp}
<span class="lg:hidden">Columns</span> <Select.Item value={esp} class="rounded-lg">{esp}</Select.Item>
<ChevronDownIcon />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-56">
{#each table as column}
<DropdownMenu.CheckboxItem class="capitalize" bind:checked={column.visible}>
{column.key}
</DropdownMenu.CheckboxItem>
{/each} {/each}
</DropdownMenu.Content> </Select.Content>
</DropdownMenu.Root> </Select.Root>
<Select.Root type="single" bind:value={selectedSensor}>
<Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a metric">
{sensorLabel}
</Select.Trigger>
<Select.Content class="rounded-xl">
<Select.Item value="altitude" class="rounded-lg">Altitude (m)</Select.Item>
<Select.Item value="humidity" class="rounded-lg">Humidity (%)</Select.Item>
<Select.Item value="pressure" class="rounded-lg">Pressure (kPa)</Select.Item>
<Select.Item value="temperature" class="rounded-lg">Temperature (°C)</Select.Item>
</Select.Content>
</Select.Root>
<Select.Root type="single" bind:value={timeRange}> <Select.Root type="single" bind:value={timeRange}>
<Select.Trigger class="w-[160px] rounded-lg sm:ml-auto" aria-label="Select a value"> <Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a time range">
{selectedLabel} {selectedLabel}
</Select.Trigger> </Select.Trigger>
<Select.Content class="rounded-xl"> <Select.Content class="rounded-xl">
@ -356,77 +416,221 @@
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm" onclick={() => exportCSV()}>
<DownloadIcon /> <DownloadIcon />
<span class="hidden lg:inline">Export Data</span> <span class="hidden lg:inline">Export Data</span>
</Button> </Button>
</div> </div>
<ChartContainer config={chartConfig} class="aspect-auto h-[800px] w-full"> {#if isLoading}
<AreaChart <div class="flex h-[400px] items-center justify-center">
legend <div class="text-center">
data={filteredData} <div
x="date" class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600"
xScale={scaleUtc()} ></div>
series={tables} <p class="text-muted-foreground">Loading statistics...</p>
seriesLayout="stack" </div>
props={{ </div>
area: { {:else if hasError}
curve: curveNatural, <div class="flex h-[400px] items-center justify-center">
"fill-opacity": 0.4, <div class="text-center">
line: { class: "stroke-1" }, <p class="mb-2 text-red-600">Error loading statistics</p>
motion: "tween", <p class="text-muted-foreground text-sm">Please try again later</p>
}, </div>
xAxis: { </div>
ticks: timeRange === "7d" ? 7 : undefined, {:else if newChartData.length === 0}
format: (v) => { <div class="flex h-[400px] items-center justify-center">
return v.toLocaleDateString("en-US", { <div class="text-center">
month: "short", <p class="mb-2 text-lg font-semibold">No data available</p>
day: "numeric", <p class="text-muted-foreground text-sm">
}); Statistics will appear once sensor data is collected
}, </p>
}, </div>
</div>
{:else}
<Card.Root>
<Card.Header>
<Card.Title class="text-xl">{selectedSensorConfig.label}</Card.Title>
</Card.Header>
<Card.Content>
<div class="space-y-4">
{#if filteredData.length > 0}
<!-- Statistics Summary -->
<div class="grid grid-cols-3 gap-4 rounded border p-4 text-center">
<div>
<div class="text-muted-foreground text-sm font-medium">Latest</div>
<div class="text-lg font-semibold">
{filteredData[filteredData.length - 1]?.[selectedSensor]?.toFixed(1)}
{#if selectedSensor === "temperature"}°C
{:else if selectedSensor === "humidity"}%
{:else if selectedSensor === "pressure"}kPa
{:else if selectedSensor === "altitude"}m
{/if}
</div>
</div>
<div>
<div class="text-muted-foreground text-sm font-medium">Average</div>
<div class="text-lg font-semibold">
{(
filteredData.reduce((sum, item) => sum + item[selectedSensor], 0) /
filteredData.length
).toFixed(1)}
{#if selectedSensor === "temperature"}°C
{:else if selectedSensor === "humidity"}%
{:else if selectedSensor === "pressure"}kPa
{:else if selectedSensor === "altitude"}m
{/if}
</div>
</div>
<div>
<div class="text-muted-foreground text-sm font-medium">Range</div>
<div class="text-lg font-semibold">
{Math.min(...filteredData.map((d) => d[selectedSensor])).toFixed(1)} - {Math.max(
...filteredData.map((d) => d[selectedSensor]),
).toFixed(1)}
{#if selectedSensor === "temperature"}°C
{:else if selectedSensor === "humidity"}%
{:else if selectedSensor === "pressure"}kPa
{:else if selectedSensor === "altitude"}m
{/if}
</div>
</div>
</div>
yAxis: { format: () => "" }, <!-- Simple SVG Line Chart -->
}} <div class="rounded border bg-white p-4">
> <svg viewBox="0 0 800 400" class="h-80 w-full">
{#snippet marks({ series, getAreaProps })} <!-- Chart area -->
<defs> {#if filteredData.length > 1}
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1"> {@const minValue = Math.min(...filteredData.map((d) => d[selectedSensor]))}
<stop offset="5%" stop-color="var(--color-desktop)" stop-opacity={1.0} /> {@const maxValue = Math.max(...filteredData.map((d) => d[selectedSensor]))}
<stop offset="95%" stop-color="var(--color-desktop)" stop-opacity={0.1} /> {@const valueRange = maxValue - minValue || 1}
</linearGradient> {@const padding = 30}
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1"> {@const chartWidth = 800 - padding * 2}
<stop offset="5%" stop-color="var(--color-mobile)" stop-opacity={0.8} /> {@const chartHeight = 400 - padding * 2}
<stop offset="95%" stop-color="var(--color-mobile)" stop-opacity={0.1} />
</linearGradient> <!-- Background grid -->
</defs> <defs>
<ChartClipPath <pattern id="grid" width="50" height="40" patternUnits="userSpaceOnUse">
initialWidth={0} <path
motion={{ d="M 50 0 L 0 0 0 40"
width: { type: "tween", duration: 1000, easing: cubicInOut }, fill="none"
}} stroke="#f0f0f0"
> stroke-width="1"
{#each series as s, i (s.key)} />
<Area </pattern>
{...getAreaProps(s, i)} </defs>
fill={s.key === "desktop" ? "url(#fillDesktop)" : "url(#fillMobile)"} <rect
/> x={padding}
{/each} y={padding}
</ChartClipPath> width={chartWidth}
{/snippet} height={chartHeight}
{#snippet tooltip()} fill="url(#grid)"
<Chart.Tooltip />
labelFormatter={(v: Date) => {
return v.toLocaleDateString("en-US", { <!-- Y-axis labels -->
month: "long", <text x="25" y={padding + 5} class="fill-gray-600 text-xs" text-anchor="end"
}); >{maxValue.toFixed(1)}</text
}} >
indicator="line" <text
/> x="25"
{/snippet} y={padding + chartHeight / 2 + 5}
</AreaChart> class="fill-gray-600 text-xs"
</ChartContainer> text-anchor="end">{((minValue + maxValue) / 2).toFixed(1)}</text
>
<text
x="25"
y={padding + chartHeight + 5}
class="fill-gray-600 text-xs"
text-anchor="end">{minValue.toFixed(1)}</text
>
<!-- X-axis labels -->
{#if filteredData.length > 0}
{@const firstDate = filteredData[0].date}
{@const lastDate = filteredData[filteredData.length - 1].date}
{@const midIndex = Math.floor(filteredData.length / 2)}
{@const midDate = filteredData[midIndex].date}
<!-- Format dates based on time range -->
{@const formatDate = (date) => {
if (timeRange === "7d") {
return date.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
} else if (timeRange === "30d") {
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
} else {
return date.toLocaleDateString("en-US", { month: "short" });
}
}}
<text
x={padding}
y={padding + chartHeight + 20}
class="fill-gray-600 text-xs"
text-anchor="start">{formatDate(firstDate)}</text
>
<text
x={padding + chartWidth / 2}
y={padding + chartHeight + 20}
class="fill-gray-600 text-xs"
text-anchor="middle">{formatDate(midDate)}</text
>
<text
x={padding + chartWidth}
y={padding + chartHeight + 20}
class="fill-gray-600 text-xs"
text-anchor="end">{formatDate(lastDate)}</text
>
{/if}
<!-- Data line -->
<polyline
fill="none"
stroke="#3b82f6"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
points={filteredData
.map((d, i) => {
const x = padding + (i / (filteredData.length - 1)) * chartWidth;
const y =
padding +
chartHeight -
((d[selectedSensor] - minValue) / valueRange) * chartHeight;
return `${x},${y}`;
})
.join(" ")}
/>
{/if}
</svg>
</div>
<div class="text-muted-foreground text-center text-xs">
Data from {filteredData[0]?.date?.toLocaleDateString()} to {filteredData[
filteredData.length - 1
]?.date?.toLocaleDateString()}
</div>
{:else}
<div class="flex h-[400px] items-center justify-center">
<div class="text-center">
<p class="mb-2 text-lg font-semibold">No data available</p>
<p class="text-muted-foreground text-sm">
No data found for the selected time period
</p>
</div>
</div>
{/if}
</div>
</Card.Content>
</Card.Root>
{/if}
<Dialog.Footer></Dialog.Footer> <Dialog.Footer></Dialog.Footer>
</Dialog.Content> </Dialog.Content>

View file

@ -1,9 +1,28 @@
import { db } from "$lib/server/db"; import { db } from "$lib/server/db";
import * as table from "$lib/server/db/schema" import * as table from "$lib/server/db/schema"
import { eq } from "drizzle-orm";
export const GET = async () => { export const GET = async (event) => {
const data = await db.select({ altitude: table.sensorData.altitude, humidity: table.sensorData.humidity, pressure: table.sensorData.pressure, temperature: table.sensorData.temperature, date: table.sensorData.time, }).from(table.sensorData); // Get all sensor data (like the original working version)
console.log(data); console.log(event.locals.session.userId)
const rawData = await db.select({
altitude: table.sensorData.altitude,
humidity: table.sensorData.humidity,
pressure: table.sensorData.pressure,
temperature: table.sensorData.temperature,
date: table.sensorData.time,
sensorId: table.sensorData.sensor,
user: table.sensors.user,
}).from(table.sensorData).innerJoin(table.sensors, eq(table.sensors.id, table.sensorData.sensor)).where(eq(table.sensors.user, event.locals.session.userId));
// Scale pressure values to be in a similar range as other sensors
// Divide by 1000 to convert from Pa to kPa (more reasonable scale)
const data = rawData.map(item => ({
...item,
pressure: Math.round((item.pressure / 1000) * 10) / 10 // Convert to kPa with 1 decimal place
}));
console.log(`Returning ${data.length} data points`);
return new Response(JSON.stringify(data), { return new Response(JSON.stringify(data), {
headers: { headers: {