···11-"use client";
22-33-import React, { useEffect, useMemo, useRef } from "react";
44-import {
55- Chart,
66- LineElement,
77- LineController,
88- PointElement,
99- LinearScale,
1010- Tooltip,
1111- TimeSeriesScale,
1212-} from "chart.js";
1313-1414-// Register needed chart.js components
1515-Chart.register(
1616- LineElement,
1717- LineController,
1818- PointElement,
1919- LinearScale,
2020- Tooltip,
2121- TimeSeriesScale,
2222-);
2323-2424-type DataPoint = {
2525- x: number; // timestamp
2626- y: number; // value
2727-};
2828-2929-type Props = {
3030- data: DataPoint[];
3131- height?: number;
3232- unit?: string;
3333- minValue?: number;
3434- maxValue?: number;
3535- minTime?: string;
3636- maxTime?: string;
3737- type: 'temperature' | 'humidity';
3838-};
3939-4040-export default function MiniChart({
4141- data,
4242- height = 60,
4343- unit = "",
4444- minValue,
4545- maxValue,
4646- minTime,
4747- maxTime,
4848- type
4949-}: Props) {
5050- const canvasRef = useRef<HTMLCanvasElement | null>(null);
5151- const chartRef = useRef<Chart | null>(null);
5252-5353- const chartColor = type === 'temperature' ? '#ef4444' : '#3b82f6';
5454-5555- // Use provided min/max values from realtime sensor data, fallback to chart data
5656- const minMaxData = useMemo(() => {
5757- if (!data.length) return null;
5858-5959- // If we have provided min/max values from the realtime display, use those
6060- if (minValue != null && maxValue != null && minTime && maxTime) {
6161- // Find the closest data points to the provided times
6262- const minTimeMs = new Date(minTime).getTime();
6363- const maxTimeMs = new Date(maxTime).getTime();
6464-6565- // Find closest data points or use the provided values with approximate times
6666- let minPoint = { x: minTimeMs, y: minValue };
6767- let maxPoint = { x: maxTimeMs, y: maxValue };
6868-6969- // Try to find actual data points close to these times
7070- for (const point of data) {
7171- if (Math.abs(point.x - minTimeMs) < Math.abs(minPoint.x - minTimeMs)) {
7272- minPoint = { x: point.x, y: minValue }; // Use real time but provided value
7373- }
7474- if (Math.abs(point.x - maxTimeMs) < Math.abs(maxPoint.x - maxTimeMs)) {
7575- maxPoint = { x: point.x, y: maxValue }; // Use real time but provided value
7676- }
7777- }
7878-7979- return { min: minPoint, max: maxPoint };
8080- }
8181-8282- // Fallback to finding min/max from chart data
8383- let min = data[0];
8484- let max = data[0];
8585-8686- for (const point of data) {
8787- if (point.y < min.y) min = point;
8888- if (point.y > max.y) max = point;
8989- }
9090-9191- return { min, max };
9292- }, [data, minValue, maxValue, minTime, maxTime]);
9393-9494- // Calculate Y-axis range with extra padding to prevent clipping of labels
9595- const yAxisRange = useMemo(() => {
9696- if (!data.length) return { min: 0, max: 100 };
9797-9898- const values = data.map(d => d.y);
9999- const dataMin = Math.min(...values);
100100- const dataMax = Math.max(...values);
101101- const range = dataMax - dataMin;
102102- // Increase padding to 15% to ensure labels don't get clipped
103103- const padding = Math.max(range * 0.15, 2); // 15% padding or minimum 2 units
104104-105105- return {
106106- min: dataMin - padding,
107107- max: dataMax + padding
108108- };
109109- }, [data]);
110110-111111- const options = useMemo(() => ({
112112- responsive: true,
113113- maintainAspectRatio: false,
114114- plugins: {
115115- legend: { display: false },
116116- tooltip: {
117117- enabled: true,
118118- mode: 'nearest' as const,
119119- intersect: false,
120120- callbacks: {
121121- title: (items: any[]) => {
122122- if (!items || !items.length) return "";
123123- const x = items[0]?.parsed?.x;
124124- if (typeof x === "number") {
125125- return new Date(x).toLocaleTimeString('de-DE', {
126126- hour: '2-digit',
127127- minute: '2-digit'
128128- });
129129- }
130130- return "";
131131- },
132132- label: (item: any) => {
133133- const val = item?.parsed?.y;
134134- return `${val?.toFixed(1)}${unit}`;
135135- },
136136- },
137137- },
138138- },
139139- scales: {
140140- x: {
141141- type: 'linear' as const,
142142- display: false,
143143- },
144144- y: {
145145- type: 'linear' as const,
146146- display: false,
147147- min: yAxisRange.min,
148148- max: yAxisRange.max,
149149- },
150150- },
151151- elements: {
152152- point: { radius: 0 },
153153- line: { tension: 0.1 },
154154- },
155155- interaction: {
156156- mode: 'nearest' as const,
157157- intersect: false,
158158- },
159159- }), [unit, yAxisRange]);
160160-161161- // Custom plugin to draw min/max annotations
162162- const annotationPlugin = useMemo(() => ({
163163- id: 'minMaxAnnotation',
164164- afterDraw: (chart: any) => {
165165- if (!minMaxData) return;
166166-167167- const { ctx, chartArea, scales } = chart;
168168- if (!chartArea || !scales?.x || !scales?.y) return;
169169-170170- ctx.save();
171171-172172- // Draw min point
173173- const minX = scales.x.getPixelForValue(minMaxData.min.x);
174174- const minY = scales.y.getPixelForValue(minMaxData.min.y);
175175-176176- if (minX >= chartArea.left && minX <= chartArea.right &&
177177- minY >= chartArea.top && minY <= chartArea.bottom) {
178178- ctx.fillStyle = '#3b82f6';
179179- ctx.beginPath();
180180- ctx.arc(minX, minY, 3, 0, 2 * Math.PI);
181181- ctx.fill();
182182-183183- // Min label
184184- ctx.fillStyle = '#3b82f6';
185185- ctx.font = '10px system-ui';
186186- ctx.textAlign = 'center';
187187- ctx.textBaseline = 'bottom';
188188- const minText = `${minMaxData.min.y.toFixed(1)}${unit}`;
189189- ctx.fillText(minText, minX, minY - 5);
190190-191191- // Min time
192192- ctx.textBaseline = 'top';
193193- const minTimeText = new Date(minMaxData.min.x).toLocaleTimeString('de-DE', {
194194- hour: '2-digit',
195195- minute: '2-digit'
196196- });
197197- ctx.fillText(minTimeText, minX, minY + 5);
198198- }
199199-200200- // Draw max point
201201- const maxX = scales.x.getPixelForValue(minMaxData.max.x);
202202- const maxY = scales.y.getPixelForValue(minMaxData.max.y);
203203-204204- if (maxX >= chartArea.left && maxX <= chartArea.right &&
205205- maxY >= chartArea.top && maxY <= chartArea.bottom) {
206206- ctx.fillStyle = '#ef4444';
207207- ctx.beginPath();
208208- ctx.arc(maxX, maxY, 3, 0, 2 * Math.PI);
209209- ctx.fill();
210210-211211- // Max label - ensure it's not clipped at top
212212- ctx.fillStyle = '#ef4444';
213213- ctx.font = '10px system-ui';
214214- ctx.textAlign = 'center';
215215- ctx.textBaseline = 'bottom';
216216- const maxText = `${minMaxData.max.y.toFixed(1)}${unit}`;
217217- // Position label below the point if it would be clipped at top
218218- const labelY = maxY - 5 < chartArea.top + 12 ? maxY + 15 : maxY - 5;
219219- ctx.fillText(maxText, maxX, labelY);
220220-221221- // Max time
222222- ctx.textBaseline = 'top';
223223- const maxTimeText = new Date(minMaxData.max.x).toLocaleTimeString('de-DE', {
224224- hour: '2-digit',
225225- minute: '2-digit'
226226- });
227227- ctx.fillText(maxTimeText, maxX, maxY + 5);
228228- }
229229-230230- ctx.restore();
231231- },
232232- }), [minMaxData, unit]);
233233-234234- const dataset = useMemo(() => ({
235235- data: data.map(point => ({ x: point.x, y: point.y })),
236236- borderColor: '#9ca3af',
237237- backgroundColor: 'transparent',
238238- borderWidth: 1,
239239- pointRadius: 0,
240240- tension: 0.1,
241241- fill: false,
242242- }), [data]);
243243-244244- // Create/update chart
245245- useEffect(() => {
246246- const ctx = canvasRef.current?.getContext("2d");
247247- if (!ctx) return;
248248-249249- if (chartRef.current) {
250250- chartRef.current.data.datasets = [dataset];
251251- chartRef.current.options = options as any;
252252- chartRef.current.config.plugins = [annotationPlugin];
253253- chartRef.current.update();
254254- return;
255255- }
256256-257257- chartRef.current = new Chart(ctx, {
258258- type: 'line',
259259- data: {
260260- datasets: [dataset],
261261- },
262262- options: options as any,
263263- plugins: [annotationPlugin],
264264- });
265265-266266- return () => {
267267- chartRef.current?.destroy();
268268- chartRef.current = null;
269269- };
270270- }, [dataset, options, annotationPlugin]);
271271-272272- if (!data.length) {
273273- return (
274274- <div
275275- className="w-full bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center text-xs text-gray-500"
276276- style={{ height }}
277277- >
278278- Keine Daten
279279- </div>
280280- );
281281- }
282282-283283- return (
284284- <div className="w-full" style={{ height }}>
285285- <canvas ref={canvasRef} />
286286- </div>
287287- );
288288-}
-158
src/components/Realtime.tsx
···44import { computeAstro, formatTime } from "@/lib/astro";
55import { API_ENDPOINTS } from "@/constants";
66import { useRealtime } from "@/contexts/RealtimeContext";
77-import MiniChart from "./MiniChart";
8798import { useTranslation } from "react-i18next";
1091110type RTData = any;
12111313-/**
1414- * A simple component to display a label and a value side-by-side.
1515- * @param props - The component props.
1616- * @param props.label - The label to display.
1717- * @param props.value - The value to display.
1818- * @returns A React component with a label and value.
1919- * @private
2020- */
2112function LabelValue({ label, value }: { label: string; value: React.ReactNode }) {
2213 return (
2314 <div className="flex items-center justify-between py-1 text-sm">
···2718 );
2819}
29203030-/**
3131- * A component to display a temperature value along with its daily min/max values.
3232- * @param props - The component props.
3333- * @returns A React component for displaying temperature with min/max data.
3434- * @private
3535- */
3621function TemperatureLabelValue({
3722 label,
3823 currentTemp,
···4833 unit?: string;
4934 t: (key: string) => string;
5035}) {
5151- const [chartData, setChartData] = useState<Array<{x: number, y: number}>>([]);
5236 const sensorData = minMax?.sensors?.[field];
53375454- // Fetch daily chart data for temperature
5555- useEffect(() => {
5656- const fetchChartData = async () => {
5757- try {
5858- const response = await fetch(`${API_ENDPOINTS.DATA_DAILY_CHART}?sensor=${field}&type=temperature&resolution=minute`);
5959- if (response.ok) {
6060- const data = await response.json();
6161- if (data.ok && data.data) {
6262- setChartData(data.data);
6363- }
6464- }
6565- } catch (error) {
6666- console.error('Error fetching temperature chart data:', error);
6767- }
6868- };
6969-7070- if (currentTemp.value != null) {
7171- fetchChartData();
7272- }
7373- }, [field, currentTemp.value]);
7474-7538 const formatMinMax = (value: number, time: string, label: string, isMax: boolean) => {
7639 const timeStr = time ? new Date(time).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '';
7740 const colorClass = isMax ? 'text-red-600' : 'text-blue-600';
···9760 {sensorData?.max != null && formatMinMax(sensorData.max, sensorData.maxTime, 'Max', true)}
9861 </div>
9962 )}
100100- {chartData.length > 0 && (
101101- <div className="mt-2">
102102- <MiniChart
103103- data={chartData}
104104- type="temperature"
105105- unit={unit}
106106- minValue={sensorData?.min}
107107- maxValue={sensorData?.max}
108108- minTime={sensorData?.minTime}
109109- maxTime={sensorData?.maxTime}
110110- />
111111- </div>
112112- )}
11363 </div>
11464 </div>
11565 </div>
11666 );
11767}
11868119119-/**
120120- * A component to display a humidity value along with its daily min/max values.
121121- * @param props - The component props.
122122- * @returns A React component for displaying humidity with min/max data.
123123- * @private
124124- */
12569function HumidityLabelValue({
12670 label,
12771 currentHumidity,
···13781 unit?: string;
13882 t: (key: string) => string;
13983}) {
140140- const [chartData, setChartData] = useState<Array<{x: number, y: number}>>([]);
14184 const sensorData = minMax?.humidity?.[field];
14285143143- // Fetch daily chart data for humidity
144144- useEffect(() => {
145145- const fetchChartData = async () => {
146146- try {
147147- const response = await fetch(`${API_ENDPOINTS.DATA_DAILY_CHART}?sensor=${field}&type=humidity&resolution=minute`);
148148- if (response.ok) {
149149- const data = await response.json();
150150- if (data.ok && data.data) {
151151- setChartData(data.data);
152152- }
153153- }
154154- } catch (error) {
155155- console.error('Error fetching humidity chart data:', error);
156156- }
157157- };
158158-159159- if (currentHumidity.value != null) {
160160- fetchChartData();
161161- }
162162- }, [field, currentHumidity.value]);
163163-16486 const formatMinMax = (value: number, time: string, label: string, isMax: boolean) => {
16587 const timeStr = time ? new Date(time).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '';
16688 const colorClass = isMax ? 'text-red-600' : 'text-blue-600';
···186108 {sensorData?.max != null && formatMinMax(sensorData.max, sensorData.maxTime, 'Max', true)}
187109 </div>
188110 )}
189189- {chartData.length > 0 && (
190190- <div className="mt-2">
191191- <MiniChart
192192- data={chartData}
193193- type="humidity"
194194- unit={unit}
195195- minValue={sensorData?.min}
196196- maxValue={sensorData?.max}
197197- minTime={sensorData?.minTime}
198198- maxTime={sensorData?.maxTime}
199199- />
200200- </div>
201201- )}
202111 </div>
203112 </div>
204113 </div>
205114 );
206115}
207116208208-/**
209209- * Displays the current value of a sensor along with its daily minimum and maximum.
210210- * @param props - The component props.
211211- * @returns A React component showing the current value and min/max stats.
212212- * @private
213213- */
214117function MinMaxDisplay({
215118 current,
216119 minMax,
···250153 );
251154}
252155253253-/**
254254- * Safely reads a nested property from an object.
255255- * @param obj - The object to read from.
256256- * @param path - The dot-separated path to the property.
257257- * @returns The property value, or undefined if not found.
258258- * @private
259259- */
260156function tryRead(obj: any, path: string): any {
261157 return path.split(".").reduce((acc, key) => (acc && key in acc ? acc[key] : undefined), obj);
262158}
263159264264-/**
265265- * Safely extracts a numeric value from various possible data structures.
266266- * @param v - The value to parse.
267267- * @returns The numeric value, or null if parsing fails.
268268- * @private
269269- */
270160function numVal(v: any): number | null {
271161 if (v == null) return null;
272162 if (typeof v === "number") return Number.isFinite(v) ? v : null;
···280170 return null;
281171}
282172283283-/**
284284- * Calculates the dew point.
285285- * @param temperature - The temperature in Celsius.
286286- * @param humidity - The relative humidity in percent.
287287- * @returns The calculated dew point in Celsius.
288288- * @private
289289- */
290173function calculateDewPoint(temperature: number, humidity: number): number {
291174 // Magnus-Formel für Taupunktberechnung
292175 const a = 17.27;
···298181 return Number.isFinite(dewPoint) ? Math.round(dewPoint * 10) / 10 : temperature;
299182}
300183301301-/**
302302- * Calculates the heat index (apparent temperature).
303303- * @param temperature - The temperature in Celsius.
304304- * @param humidity - The relative humidity in percent.
305305- * @returns The calculated heat index in Celsius.
306306- * @private
307307- */
308184function calculateHeatIndex(temperature: number, humidity: number): number {
309185 // Vereinfachte Formel für den Wärmeindex (Heat Index)
310186 if (temperature < 20) {
···333209 return Number.isFinite(heatIndex) ? Math.round(heatIndex * 10) / 10 : temperature;
334210}
335211336336-/**
337337- * Extracts a value and its unit from a potential value-unit object.
338338- * @param v - The value object.
339339- * @returns An object containing the value and an optional unit.
340340- * @private
341341- */
342212function valueAndUnit(v: any): { value: string | number | null; unit?: string } {
343213 if (v == null) return { value: null };
344214 if (typeof v === "object" && ("value" in v)) {
···347217 return { value: v };
348218}
349219350350-/**
351351- * Formats a value-unit object into a display string.
352352- * @param vu - The value-unit object.
353353- * @param fallbackUnit - A fallback unit to use if the object doesn't specify one.
354354- * @returns A formatted string like "10 °C" or "—" if the value is null.
355355- * @private
356356- */
357220function fmtVU(vu: { value: string | number | null; unit?: string }, fallbackUnit?: string) {
358221 if (vu.value == null || vu.value === "") return "—";
359222 const unit = vu.unit ?? fallbackUnit ?? "";
360223 return `${vu.value}${unit ? ` ${unit}` : ""}`;
361224}
362225363363-/**
364364- * Formats a battery status value into a localized string ("OK" or "Low").
365365- * @param v - The battery status value.
366366- * @param t - The translation function.
367367- * @returns The localized battery status.
368368- * @private
369369- */
370226function fmtBattery(v: any, t: (key: string) => string) {
371227 const vu = valueAndUnit(v);
372228 if (vu.value == null || vu.value === "") return "—";
···375231 return n === 0 ? t('statuses.ok') : t('statuses.low');
376232}
377233378378-/**
379379- * Gets a translated, human-readable label for a given data key.
380380- * @param key - The data key (e.g., "wind_speed").
381381- * @param t - The translation function.
382382- * @returns The internationalized label.
383383- * @private
384384- */
385234function i18nLabel(key: string, t: (key: string) => string): string {
386235 const k = key.toLowerCase();
387236 const map: Record<string, string> = {
···401250 return map[k] || key.replace(/_/g, " ");
402251}
403252404404-/**
405405- * The main component for displaying real-time weather data in a list format.
406406- * It fetches and displays current conditions, sensor data, astronomical information,
407407- * and battery statuses.
408408- *
409409- * @returns A React component that renders the real-time data view.
410410- */
411253export default function Realtime() {
412254 const { t, i18n } = useTranslation();
413255 const { data, error, loading, lastUpdated } = useRealtime();
+1-13
src/constants.js
···11-/**
22- * A collection of API endpoint paths used throughout the application.
33- * @property {string} RT_LAST - Endpoint for the last received real-time data.
44- * @property {string} CONFIG_CHANNELS - Endpoint for channel configuration.
55- * @property {string} DEVICE_INFO - Endpoint for device information (timezone, coordinates).
66- * @property {string} TEMP_MINMAX - Endpoint to get today's min/max temperature data.
77- * @property {string} TEMP_MINMAX_UPDATE - Endpoint to trigger an update of min/max data.
88- * @property {string} DATA_MONTHS - Endpoint to get the list of available months with data.
99- * @property {string} DATA_EXTENT - Endpoint to get the global time range of all data.
1010- * @property {string} DATA_ALLSENSORS - Endpoint for historical data from all channel sensors.
1111- * @property {string} DATA_MAIN - Endpoint for historical data from the main weather station sensors.
1212- */
11+// API Endpoints
132export const API_ENDPOINTS = {
143 // Realtime data
154 RT_LAST: '/api/rt/last',
···2918 DATA_EXTENT: '/api/data/extent',
3019 DATA_ALLSENSORS: '/api/data/allsensors',
3120 DATA_MAIN: '/api/data/main',
3232- DATA_DAILY_CHART: '/api/data/dailychart',
3321};
-254
src/lib/daily-chart.ts
···11-import { getDuckConn } from './db/duckdb';
22-import { ensureAllsensorsParquetsInRange } from './db/ingest';
33-44-interface ChartDataPoint {
55- x: number; // timestamp
66- y: number; // value
77-}
88-99-export async function getDailyChartData(sensor: string, type: 'temperature' | 'humidity'): Promise<ChartDataPoint[]> {
1010- try {
1111- const conn = await getDuckConn();
1212-1313- // Get today's date range
1414- const today = new Date();
1515- const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
1616- const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
1717-1818- // Get parquet files for today
1919- const parquetFiles = await ensureAllsensorsParquetsInRange(startOfDay, endOfDay);
2020- if (!parquetFiles.length) {
2121- console.warn('No parquet files found for today');
2222- return [];
2323- }
2424-2525- // Map sensor names to exact column names in allsensors data
2626- // Note: allsensors data only contains CH1-CH8 sensors, no indoor/outdoor
2727- const getSensorColumn = (sensor: string, type: 'temperature' | 'humidity'): string[] => {
2828- const candidates: string[] = [];
2929-3030- if (sensor === 'indoor') {
3131- // Indoor sensors don't exist in allsensors data - map to CH1 as fallback
3232- if (type === 'temperature') {
3333- candidates.push('CH1 Temperature(℃)');
3434- } else {
3535- candidates.push('CH1 Luftfeuchtigkeit(%)');
3636- }
3737- } else if (sensor === 'outdoor') {
3838- // Outdoor sensors don't exist in allsensors data - map to CH2 as fallback
3939- if (type === 'temperature') {
4040- candidates.push('CH2 Temperature(℃)');
4141- } else {
4242- candidates.push('CH2 Luftfeuchtigkeit(%)');
4343- }
4444- } else if (sensor.match(/temp_and_humidity_ch(\d+)/)) {
4545- const chNum = sensor.match(/temp_and_humidity_ch(\d+)/)?.[1];
4646- if (type === 'temperature') {
4747- // Exact German column names from the data
4848- candidates.push(`CH${chNum} Temperature(℃)`);
4949- } else {
5050- // Exact German column names from the data
5151- candidates.push(`CH${chNum} Luftfeuchtigkeit(%)`);
5252- }
5353- }
5454-5555- return candidates;
5656- };
5757-5858- const columnCandidates = getSensorColumn(sensor, type);
5959-6060- // Build union of all parquet files
6161- const unionSources = parquetFiles.map((p) => `SELECT * FROM read_parquet('${p.replace(/\\/g, "/")}')`).join("\nUNION ALL\n");
6262-6363- // First, get available columns to find the right one
6464- const first = parquetFiles[0].replace(/\\/g, "/");
6565- const describeSql = `DESCRIBE SELECT * FROM read_parquet('${first}')`;
6666- const descReader = await conn.runAndReadAll(describeSql);
6767- const cols: any[] = descReader.getRowObjects();
6868- const availableColumns = cols.map((r: any) => String(r.column_name || r.ColumnName || r.column || ""));
6969-7070- // Find matching column
7171- let targetColumn = null;
7272- for (const candidate of columnCandidates) {
7373- const found = availableColumns.find(col =>
7474- col.toLowerCase().includes(candidate.toLowerCase()) ||
7575- candidate.toLowerCase().includes(col.toLowerCase())
7676- );
7777- if (found) {
7878- targetColumn = found;
7979- break;
8080- }
8181- }
8282-8383- if (!targetColumn) {
8484- console.warn(`No matching column found for ${sensor} ${type}. Available columns:`, availableColumns);
8585- return [];
8686- }
8787-8888- // Format dates for DuckDB
8989- const startStr = startOfDay.toISOString().replace('T', ' ').slice(0, 16);
9090- const endStr = endOfDay.toISOString().replace('T', ' ').slice(0, 16);
9191-9292- // Query to get hourly averages for the day
9393- const query = `
9494- WITH src AS (
9595- ${unionSources}
9696- ),
9797- filt AS (
9898- SELECT * FROM src
9999- WHERE ts IS NOT NULL
100100- AND ts >= strptime('${startStr}', '%Y-%m-%d %H:%M')
101101- AND ts < strptime('${endStr}', '%Y-%m-%d %H:%M')
102102- AND "${targetColumn}" IS NOT NULL
103103- )
104104- SELECT
105105- EXTRACT(EPOCH FROM date_trunc('hour', ts)) * 1000 as x,
106106- AVG(CAST("${targetColumn}" AS DOUBLE)) as y
107107- FROM filt
108108- GROUP BY date_trunc('hour', ts)
109109- ORDER BY x
110110- `;
111111-112112- const result = await conn.runAndReadAll(query);
113113-114114- const data: ChartDataPoint[] = [];
115115- const rows = result.getRowObjects();
116116- for (const row of rows) {
117117- const x = row.x as number;
118118- const y = row.y as number;
119119- if (x != null && y != null && isFinite(y)) {
120120- data.push({ x, y });
121121- }
122122- }
123123-124124- return data;
125125-126126- } catch (error) {
127127- console.error(`Error fetching daily chart data for ${sensor} ${type}:`, error);
128128- return [];
129129- }
130130-}
131131-132132-export async function getDailyChartDataMinute(sensor: string, type: 'temperature' | 'humidity'): Promise<ChartDataPoint[]> {
133133- try {
134134- const conn = await getDuckConn();
135135-136136- // Get today's date range
137137- const today = new Date();
138138- const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
139139- const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
140140-141141- // Get parquet files for today
142142- const parquetFiles = await ensureAllsensorsParquetsInRange(startOfDay, endOfDay);
143143- if (!parquetFiles.length) {
144144- console.warn('No parquet files found for today');
145145- return [];
146146- }
147147-148148- // Map sensor names to exact column names in allsensors data
149149- // Note: allsensors data only contains CH1-CH8 sensors, no indoor/outdoor
150150- const getSensorColumn = (sensor: string, type: 'temperature' | 'humidity'): string[] => {
151151- const candidates: string[] = [];
152152-153153- if (sensor === 'indoor') {
154154- // Indoor sensors don't exist in allsensors data - map to CH1 as fallback
155155- if (type === 'temperature') {
156156- candidates.push('CH1 Temperature(℃)');
157157- } else {
158158- candidates.push('CH1 Luftfeuchtigkeit(%)');
159159- }
160160- } else if (sensor === 'outdoor') {
161161- // Outdoor sensors don't exist in allsensors data - map to CH2 as fallback
162162- if (type === 'temperature') {
163163- candidates.push('CH2 Temperature(℃)');
164164- } else {
165165- candidates.push('CH2 Luftfeuchtigkeit(%)');
166166- }
167167- } else if (sensor.match(/temp_and_humidity_ch(\d+)/)) {
168168- const chNum = sensor.match(/temp_and_humidity_ch(\d+)/)?.[1];
169169- if (type === 'temperature') {
170170- // Exact German column names from the data
171171- candidates.push(`CH${chNum} Temperature(℃)`);
172172- } else {
173173- // Exact German column names from the data
174174- candidates.push(`CH${chNum} Luftfeuchtigkeit(%)`);
175175- }
176176- }
177177-178178- return candidates;
179179- };
180180-181181- const columnCandidates = getSensorColumn(sensor, type);
182182-183183- // Build union of all parquet files
184184- const unionSources = parquetFiles.map((p) => `SELECT * FROM read_parquet('${p.replace(/\\/g, "/")}')`).join("\nUNION ALL\n");
185185-186186- // First, get available columns to find the right one
187187- const first = parquetFiles[0].replace(/\\/g, "/");
188188- const describeSql = `DESCRIBE SELECT * FROM read_parquet('${first}')`;
189189- const descReader = await conn.runAndReadAll(describeSql);
190190- const cols: any[] = descReader.getRowObjects();
191191- const availableColumns = cols.map((r: any) => String(r.column_name || r.ColumnName || r.column || ""));
192192-193193- // Find matching column
194194- let targetColumn = null;
195195- for (const candidate of columnCandidates) {
196196- const found = availableColumns.find(col =>
197197- col.toLowerCase().includes(candidate.toLowerCase()) ||
198198- candidate.toLowerCase().includes(col.toLowerCase())
199199- );
200200- if (found) {
201201- targetColumn = found;
202202- break;
203203- }
204204- }
205205-206206- if (!targetColumn) {
207207- console.warn(`No matching column found for ${sensor} ${type}. Available columns:`, availableColumns);
208208- return [];
209209- }
210210-211211- // Format dates for DuckDB
212212- const startStr = startOfDay.toISOString().replace('T', ' ').slice(0, 16);
213213- const endStr = endOfDay.toISOString().replace('T', ' ').slice(0, 16);
214214-215215- // Query to get 5-minute averages for the day
216216- const query = `
217217- WITH src AS (
218218- ${unionSources}
219219- ),
220220- filt AS (
221221- SELECT * FROM src
222222- WHERE ts IS NOT NULL
223223- AND ts >= strptime('${startStr}', '%Y-%m-%d %H:%M')
224224- AND ts < strptime('${endStr}', '%Y-%m-%d %H:%M')
225225- AND "${targetColumn}" IS NOT NULL
226226- AND EXTRACT(MINUTE FROM ts) % 5 = 0
227227- )
228228- SELECT
229229- EXTRACT(EPOCH FROM date_trunc('minute', ts)) * 1000 as x,
230230- AVG(CAST("${targetColumn}" AS DOUBLE)) as y
231231- FROM filt
232232- GROUP BY date_trunc('minute', ts)
233233- ORDER BY x
234234- `;
235235-236236- const result = await conn.runAndReadAll(query);
237237-238238- const data: ChartDataPoint[] = [];
239239- const rows = result.getRowObjects();
240240- for (const row of rows) {
241241- const x = row.x as number;
242242- const y = row.y as number;
243243- if (x != null && y != null && isFinite(y)) {
244244- data.push({ x, y });
245245- }
246246- }
247247-248248- return data;
249249-250250- } catch (error) {
251251- console.error(`Error fetching minute daily chart data for ${sensor} ${type}:`, error);
252252- return [];
253253- }
254254-}
-7
src/lib/temp-minmax.ts
···3838 try {
3939 if (fs.existsSync(DATA_FILE)) {
4040 const content = fs.readFileSync(DATA_FILE, 'utf8');
4141-4242- // Check if file is empty or contains only whitespace
4343- if (!content.trim()) {
4444- console.warn('temp-minmax-data.json is empty, returning null');
4545- return null;
4646- }
4747-4841 const data = JSON.parse(content);
4942 const today = getTodayDate();
5043