Compare commits

..

No commits in common. "ca805be243216f0ed4d80b65f15c7b9d71e14732" and "91ca53880b9c2120af2a3e22e9d6d4ab1fc2918b" have entirely different histories.

7 changed files with 4 additions and 445 deletions

View file

@ -68,8 +68,6 @@
"@node-rs/argon2": "^2.0.2", "@node-rs/argon2": "^2.0.2",
"@oslojs/crypto": "^1.0.1", "@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0", "@oslojs/encoding": "^1.1.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"drizzle-orm": "^0.43.1", "drizzle-orm": "^0.43.1",
"mqtt": "^5.13.1", "mqtt": "^5.13.1",
"postgres": "^3.4.5", "postgres": "^3.4.5",

6
pnpm-lock.yaml generated
View file

@ -17,12 +17,6 @@ importers:
'@oslojs/encoding': '@oslojs/encoding':
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0 version: 1.1.0
d3-scale:
specifier: ^4.0.2
version: 4.0.2
d3-shape:
specifier: ^3.2.0
version: 3.2.0
drizzle-orm: drizzle-orm:
specifier: ^0.43.1 specifier: ^0.43.1
version: 0.43.1(postgres@3.4.5) version: 0.43.1(postgres@3.4.5)

View file

@ -34,7 +34,7 @@ export const sensors = pgTable("sensors", {
}); });
export const sensorData = pgTable("sensor_data", { export const sensorData = pgTable("sensor_data", {
uuid: uuid().primaryKey().defaultRandom(), uuid: uuid().primaryKey(),
sensor: text("sensor") sensor: text("sensor")
.references(() => sensors.id) .references(() => sensors.id)
.notNull(), .notNull(),
@ -42,5 +42,4 @@ export const sensorData = pgTable("sensor_data", {
humidity: real().notNull(), humidity: real().notNull(),
pressure: real().notNull(), pressure: real().notNull(),
altitude: real().notNull(), altitude: real().notNull(),
time: timestamp({ withTimezone: true, mode: "date" }).notNull().defaultNow(),
}); });

View file

@ -1,10 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { Thermometer, Droplets, Gauge, Mountain, Cpu, DownloadIcon } from "@lucide/svelte"; import { Thermometer, Droplets, Gauge, Mountain, Cpu } from "@lucide/svelte";
import { Button, buttonVariants } from "$lib/components/ui/button/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import * as Dialog from "$lib/components/ui/dialog/index.js";
const { data } = $props(); const { data } = $props();
@ -124,129 +120,9 @@
drawFloorplan(data.floor[0].json); drawFloorplan(data.floor[0].json);
} }
}); });
import * as Chart from "$lib/components/ui/chart/index.js";
import * as Card from "$lib/components/ui/card/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 timeRange = $state("90d");
const selectedLabel = $derived.by(() => {
switch (timeRange) {
case "90d":
return "Last 3 months";
case "30d":
return "Last 30 days";
case "7d":
return "Last 7 days";
default:
return "Last 3 months";
}
});
const filteredData = $derived(
chartData.filter((item) => {
const now = new Date();
let daysToSubtract = 90;
if (timeRange === "30d") {
daysToSubtract = 30;
} else if (timeRange === "7d") {
daysToSubtract = 7;
}
const cutoffDate = new Date(now.getTime() - daysToSubtract * 24 * 60 * 60 * 1000);
return item.date >= cutoffDate;
}),
);
const chartConfig = {
temperature: { label: "Temperature (°C)", color: "var(--chart-1)" },
humidity: { label: "Humidity (%)", color: "var(--chart-2)" },
altitude: { label: "Altitude (m)", color: "var(--chart-3)" },
pressure: { label: "Pressure (kPa)", color: "var(--chart-4)" },
} satisfies Chart.ChartConfig;
let selectedSensor = $state("temperature");
const sensorOptions = [
{
key: "temperature",
label: chartConfig.temperature.label,
color: chartConfig.temperature.color,
},
{
key: "humidity",
label: chartConfig.humidity.label,
color: chartConfig.humidity.color,
},
{
key: "pressure",
label: chartConfig.pressure.label,
color: chartConfig.pressure.color,
},
{
key: "altitude",
label: chartConfig.altitude.label,
color: chartConfig.altitude.color,
},
];
const selectedSensorConfig = $derived(
sensorOptions.find((s) => s.key === selectedSensor) || sensorOptions[0],
);
let isLoading = $state(false);
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);
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;
}
}
});
</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}
@ -333,267 +209,4 @@
<canvas bind:this={canvasEl} class="border" width="800" height="600"></canvas> <canvas bind:this={canvasEl} class="border" width="800" height="600"></canvas>
</div> </div>
{/if} {/if}
<Dialog.Root bind:onOpenChange>
<Dialog.Trigger class={buttonVariants({ variant: "outline" })}>Statistics</Dialog.Trigger>
<Dialog.Content class="sm:max-h-[90%] sm:max-w-[90%]">
<Dialog.Header>
<div class="grid flex-1 gap-1 text-center sm:text-left">
<Card.Title>Statistics for floor {data.slug}</Card.Title>
<Card.Description>Showing total data for the last 3 months</Card.Description>
</div>
</Dialog.Header>
<div class="flex items-center gap-2">
<DropdownMenu.Root>
<DropdownMenu.Trigger class="rounded-lg">
{#snippet child({ props })}
<Button variant="outline" size="sm" {...props}>
<Columns2 />
<span class="hidden lg:inline">{selectedSensorConfig.label}</span>
<span class="lg:hidden">{selectedSensorConfig.label}</span>
<ChevronDownIcon />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-56">
<DropdownMenu.RadioGroup bind:value={selectedSensor}>
{#each sensorOptions as sensor}
<DropdownMenu.RadioItem value={sensor.key} class="capitalize">
{sensor.label}
</DropdownMenu.RadioItem>
{/each}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Select.Root type="single" bind:value={timeRange}>
<Select.Trigger class="w-[160px] rounded-lg" aria-label="Select a value">
{selectedLabel}
</Select.Trigger>
<Select.Content class="rounded-xl">
<Select.Item value="90d" class="rounded-lg">Last 3 months</Select.Item>
<Select.Item value="30d" class="rounded-lg">Last 30 days</Select.Item>
<Select.Item value="7d" class="rounded-lg">Last 7 days</Select.Item>
</Select.Content>
</Select.Root>
<Button variant="outline" size="sm">
<DownloadIcon />
<span class="hidden lg:inline">Export Data</span>
</Button>
</div>
{#if isLoading}
<div class="flex h-[400px] items-center justify-center">
<div class="text-center">
<div
class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600"
></div>
<p class="text-muted-foreground">Loading statistics...</p>
</div>
</div>
{:else if hasError}
<div class="flex h-[400px] items-center justify-center">
<div class="text-center">
<p class="mb-2 text-red-600">Error loading statistics</p>
<p class="text-muted-foreground text-sm">Please try again later</p>
</div>
</div>
{:else if chartData.length === 0}
<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">
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>
<!-- Simple SVG Line Chart -->
<div class="rounded border bg-white p-4">
<svg viewBox="0 0 800 400" class="h-80 w-full">
<!-- Chart area -->
{#if filteredData.length > 1}
{@const minValue = Math.min(...filteredData.map((d) => d[selectedSensor]))}
{@const maxValue = Math.max(...filteredData.map((d) => d[selectedSensor]))}
{@const valueRange = maxValue - minValue || 1}
{@const padding = 30}
{@const chartWidth = 800 - padding * 2}
{@const chartHeight = 400 - padding * 2}
<!-- Background grid -->
<defs>
<pattern id="grid" width="50" height="40" patternUnits="userSpaceOnUse">
<path
d="M 50 0 L 0 0 0 40"
fill="none"
stroke="#f0f0f0"
stroke-width="1"
/>
</pattern>
</defs>
<rect
x={padding}
y={padding}
width={chartWidth}
height={chartHeight}
fill="url(#grid)"
/>
<!-- Y-axis labels -->
<text x="25" y={padding + 5} class="fill-gray-600 text-xs" text-anchor="end"
>{maxValue.toFixed(1)}</text
>
<text
x="25"
y={padding + chartHeight / 2 + 5}
class="fill-gray-600 text-xs"
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.Content>
</Dialog.Root>
</div> </div>

View file

@ -1,28 +0,0 @@
import { db } from "$lib/server/db";
import * as table from "$lib/server/db/schema"
export const GET = async () => {
// Get all sensor data (like the original working version)
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,
}).from(table.sensorData);
// 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), {
headers: {
"Content-Type": "application/json"
}
});
}

View file

@ -81,9 +81,6 @@ export const actions = {
try { try {
const deviceData = JSON.parse(devices); const deviceData = JSON.parse(devices);
deviceData.forEach(async (dev) => {
await db.insert(table.sensors).values({ id: dev.id, user: event.locals.session.userId });
});
// Check if floor exists // Check if floor exists
const exists = await db const exists = await db

View file

@ -1,9 +1,6 @@
// src/routes/mqtt/+server.js // src/routes/mqtt/+server.js
import { db } from "$lib/server/db";
import * as table from "$lib/server/db/schema";
import { connectedDevices, deviceSensorData, getCurrentDevices } from "$lib/server/mqtt-devices.js"; import { connectedDevices, deviceSensorData, getCurrentDevices } from "$lib/server/mqtt-devices.js";
import { eq } from "drizzle-orm"; import * as mqtt from "mqtt";
import mqtt from "mqtt";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
// A Svelte store to hold the latest MQTT message. // A Svelte store to hold the latest MQTT message.
@ -117,7 +114,7 @@ function connectMqtt() {
}); });
}); });
client.on("message", async (topic, message) => { client.on("message", (topic, message) => {
const payload = message.toString(); const payload = message.toString();
console.log(`Received message from topic "${topic}": ${payload}`); console.log(`Received message from topic "${topic}": ${payload}`);
latestMessage.set(payload); // Update the Svelte store latestMessage.set(payload); // Update the Svelte store
@ -133,17 +130,6 @@ function connectMqtt() {
if (sensorData) { if (sensorData) {
console.log(`Parsed sensor data:`, sensorData); console.log(`Parsed sensor data:`, sensorData);
updateDevice(deviceId, sensorData); updateDevice(deviceId, sensorData);
const devices = await db.select().from(table.sensors).where(eq(table.sensors.id, deviceId));
if (devices.length == 1)
await db
.insert(table.sensorData)
.values({
sensor: deviceId,
temperature: sensorData.temperature,
humidity: sensorData.humidity,
altitude: sensorData.altitude,
pressure: sensorData.pressure,
});
} else { } else {
// Still update device as online even if data parsing failed // Still update device as online even if data parsing failed
updateDevice(deviceId); updateDevice(deviceId);