Weather Station / ECOWITT / DNT
0

Configure Feed

Select the types of activity you want to include in your feed.

revert minicharts

+72 -791
+2 -2
.gitignore
··· 46 46 /data/parquet/ 47 47 /data/*.duckdb* 48 48 eco.ts 49 - /temp* 50 - temp-minmax-data.json 49 + #/temp* 50 + #temp-minmax-data.json
-288
src/components/MiniChart.tsx
··· 1 - "use client"; 2 - 3 - import React, { useEffect, useMemo, useRef } from "react"; 4 - import { 5 - Chart, 6 - LineElement, 7 - LineController, 8 - PointElement, 9 - LinearScale, 10 - Tooltip, 11 - TimeSeriesScale, 12 - } from "chart.js"; 13 - 14 - // Register needed chart.js components 15 - Chart.register( 16 - LineElement, 17 - LineController, 18 - PointElement, 19 - LinearScale, 20 - Tooltip, 21 - TimeSeriesScale, 22 - ); 23 - 24 - type DataPoint = { 25 - x: number; // timestamp 26 - y: number; // value 27 - }; 28 - 29 - type Props = { 30 - data: DataPoint[]; 31 - height?: number; 32 - unit?: string; 33 - minValue?: number; 34 - maxValue?: number; 35 - minTime?: string; 36 - maxTime?: string; 37 - type: 'temperature' | 'humidity'; 38 - }; 39 - 40 - export default function MiniChart({ 41 - data, 42 - height = 60, 43 - unit = "", 44 - minValue, 45 - maxValue, 46 - minTime, 47 - maxTime, 48 - type 49 - }: Props) { 50 - const canvasRef = useRef<HTMLCanvasElement | null>(null); 51 - const chartRef = useRef<Chart | null>(null); 52 - 53 - const chartColor = type === 'temperature' ? '#ef4444' : '#3b82f6'; 54 - 55 - // Use provided min/max values from realtime sensor data, fallback to chart data 56 - const minMaxData = useMemo(() => { 57 - if (!data.length) return null; 58 - 59 - // If we have provided min/max values from the realtime display, use those 60 - if (minValue != null && maxValue != null && minTime && maxTime) { 61 - // Find the closest data points to the provided times 62 - const minTimeMs = new Date(minTime).getTime(); 63 - const maxTimeMs = new Date(maxTime).getTime(); 64 - 65 - // Find closest data points or use the provided values with approximate times 66 - let minPoint = { x: minTimeMs, y: minValue }; 67 - let maxPoint = { x: maxTimeMs, y: maxValue }; 68 - 69 - // Try to find actual data points close to these times 70 - for (const point of data) { 71 - if (Math.abs(point.x - minTimeMs) < Math.abs(minPoint.x - minTimeMs)) { 72 - minPoint = { x: point.x, y: minValue }; // Use real time but provided value 73 - } 74 - if (Math.abs(point.x - maxTimeMs) < Math.abs(maxPoint.x - maxTimeMs)) { 75 - maxPoint = { x: point.x, y: maxValue }; // Use real time but provided value 76 - } 77 - } 78 - 79 - return { min: minPoint, max: maxPoint }; 80 - } 81 - 82 - // Fallback to finding min/max from chart data 83 - let min = data[0]; 84 - let max = data[0]; 85 - 86 - for (const point of data) { 87 - if (point.y < min.y) min = point; 88 - if (point.y > max.y) max = point; 89 - } 90 - 91 - return { min, max }; 92 - }, [data, minValue, maxValue, minTime, maxTime]); 93 - 94 - // Calculate Y-axis range with extra padding to prevent clipping of labels 95 - const yAxisRange = useMemo(() => { 96 - if (!data.length) return { min: 0, max: 100 }; 97 - 98 - const values = data.map(d => d.y); 99 - const dataMin = Math.min(...values); 100 - const dataMax = Math.max(...values); 101 - const range = dataMax - dataMin; 102 - // Increase padding to 15% to ensure labels don't get clipped 103 - const padding = Math.max(range * 0.15, 2); // 15% padding or minimum 2 units 104 - 105 - return { 106 - min: dataMin - padding, 107 - max: dataMax + padding 108 - }; 109 - }, [data]); 110 - 111 - const options = useMemo(() => ({ 112 - responsive: true, 113 - maintainAspectRatio: false, 114 - plugins: { 115 - legend: { display: false }, 116 - tooltip: { 117 - enabled: true, 118 - mode: 'nearest' as const, 119 - intersect: false, 120 - callbacks: { 121 - title: (items: any[]) => { 122 - if (!items || !items.length) return ""; 123 - const x = items[0]?.parsed?.x; 124 - if (typeof x === "number") { 125 - return new Date(x).toLocaleTimeString('de-DE', { 126 - hour: '2-digit', 127 - minute: '2-digit' 128 - }); 129 - } 130 - return ""; 131 - }, 132 - label: (item: any) => { 133 - const val = item?.parsed?.y; 134 - return `${val?.toFixed(1)}${unit}`; 135 - }, 136 - }, 137 - }, 138 - }, 139 - scales: { 140 - x: { 141 - type: 'linear' as const, 142 - display: false, 143 - }, 144 - y: { 145 - type: 'linear' as const, 146 - display: false, 147 - min: yAxisRange.min, 148 - max: yAxisRange.max, 149 - }, 150 - }, 151 - elements: { 152 - point: { radius: 0 }, 153 - line: { tension: 0.1 }, 154 - }, 155 - interaction: { 156 - mode: 'nearest' as const, 157 - intersect: false, 158 - }, 159 - }), [unit, yAxisRange]); 160 - 161 - // Custom plugin to draw min/max annotations 162 - const annotationPlugin = useMemo(() => ({ 163 - id: 'minMaxAnnotation', 164 - afterDraw: (chart: any) => { 165 - if (!minMaxData) return; 166 - 167 - const { ctx, chartArea, scales } = chart; 168 - if (!chartArea || !scales?.x || !scales?.y) return; 169 - 170 - ctx.save(); 171 - 172 - // Draw min point 173 - const minX = scales.x.getPixelForValue(minMaxData.min.x); 174 - const minY = scales.y.getPixelForValue(minMaxData.min.y); 175 - 176 - if (minX >= chartArea.left && minX <= chartArea.right && 177 - minY >= chartArea.top && minY <= chartArea.bottom) { 178 - ctx.fillStyle = '#3b82f6'; 179 - ctx.beginPath(); 180 - ctx.arc(minX, minY, 3, 0, 2 * Math.PI); 181 - ctx.fill(); 182 - 183 - // Min label 184 - ctx.fillStyle = '#3b82f6'; 185 - ctx.font = '10px system-ui'; 186 - ctx.textAlign = 'center'; 187 - ctx.textBaseline = 'bottom'; 188 - const minText = `${minMaxData.min.y.toFixed(1)}${unit}`; 189 - ctx.fillText(minText, minX, minY - 5); 190 - 191 - // Min time 192 - ctx.textBaseline = 'top'; 193 - const minTimeText = new Date(minMaxData.min.x).toLocaleTimeString('de-DE', { 194 - hour: '2-digit', 195 - minute: '2-digit' 196 - }); 197 - ctx.fillText(minTimeText, minX, minY + 5); 198 - } 199 - 200 - // Draw max point 201 - const maxX = scales.x.getPixelForValue(minMaxData.max.x); 202 - const maxY = scales.y.getPixelForValue(minMaxData.max.y); 203 - 204 - if (maxX >= chartArea.left && maxX <= chartArea.right && 205 - maxY >= chartArea.top && maxY <= chartArea.bottom) { 206 - ctx.fillStyle = '#ef4444'; 207 - ctx.beginPath(); 208 - ctx.arc(maxX, maxY, 3, 0, 2 * Math.PI); 209 - ctx.fill(); 210 - 211 - // Max label - ensure it's not clipped at top 212 - ctx.fillStyle = '#ef4444'; 213 - ctx.font = '10px system-ui'; 214 - ctx.textAlign = 'center'; 215 - ctx.textBaseline = 'bottom'; 216 - const maxText = `${minMaxData.max.y.toFixed(1)}${unit}`; 217 - // Position label below the point if it would be clipped at top 218 - const labelY = maxY - 5 < chartArea.top + 12 ? maxY + 15 : maxY - 5; 219 - ctx.fillText(maxText, maxX, labelY); 220 - 221 - // Max time 222 - ctx.textBaseline = 'top'; 223 - const maxTimeText = new Date(minMaxData.max.x).toLocaleTimeString('de-DE', { 224 - hour: '2-digit', 225 - minute: '2-digit' 226 - }); 227 - ctx.fillText(maxTimeText, maxX, maxY + 5); 228 - } 229 - 230 - ctx.restore(); 231 - }, 232 - }), [minMaxData, unit]); 233 - 234 - const dataset = useMemo(() => ({ 235 - data: data.map(point => ({ x: point.x, y: point.y })), 236 - borderColor: '#9ca3af', 237 - backgroundColor: 'transparent', 238 - borderWidth: 1, 239 - pointRadius: 0, 240 - tension: 0.1, 241 - fill: false, 242 - }), [data]); 243 - 244 - // Create/update chart 245 - useEffect(() => { 246 - const ctx = canvasRef.current?.getContext("2d"); 247 - if (!ctx) return; 248 - 249 - if (chartRef.current) { 250 - chartRef.current.data.datasets = [dataset]; 251 - chartRef.current.options = options as any; 252 - chartRef.current.config.plugins = [annotationPlugin]; 253 - chartRef.current.update(); 254 - return; 255 - } 256 - 257 - chartRef.current = new Chart(ctx, { 258 - type: 'line', 259 - data: { 260 - datasets: [dataset], 261 - }, 262 - options: options as any, 263 - plugins: [annotationPlugin], 264 - }); 265 - 266 - return () => { 267 - chartRef.current?.destroy(); 268 - chartRef.current = null; 269 - }; 270 - }, [dataset, options, annotationPlugin]); 271 - 272 - if (!data.length) { 273 - return ( 274 - <div 275 - className="w-full bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center text-xs text-gray-500" 276 - style={{ height }} 277 - > 278 - Keine Daten 279 - </div> 280 - ); 281 - } 282 - 283 - return ( 284 - <div className="w-full" style={{ height }}> 285 - <canvas ref={canvasRef} /> 286 - </div> 287 - ); 288 - }
-158
src/components/Realtime.tsx
··· 4 4 import { computeAstro, formatTime } from "@/lib/astro"; 5 5 import { API_ENDPOINTS } from "@/constants"; 6 6 import { useRealtime } from "@/contexts/RealtimeContext"; 7 - import MiniChart from "./MiniChart"; 8 7 9 8 import { useTranslation } from "react-i18next"; 10 9 11 10 type RTData = any; 12 11 13 - /** 14 - * A simple component to display a label and a value side-by-side. 15 - * @param props - The component props. 16 - * @param props.label - The label to display. 17 - * @param props.value - The value to display. 18 - * @returns A React component with a label and value. 19 - * @private 20 - */ 21 12 function LabelValue({ label, value }: { label: string; value: React.ReactNode }) { 22 13 return ( 23 14 <div className="flex items-center justify-between py-1 text-sm"> ··· 27 18 ); 28 19 } 29 20 30 - /** 31 - * A component to display a temperature value along with its daily min/max values. 32 - * @param props - The component props. 33 - * @returns A React component for displaying temperature with min/max data. 34 - * @private 35 - */ 36 21 function TemperatureLabelValue({ 37 22 label, 38 23 currentTemp, ··· 48 33 unit?: string; 49 34 t: (key: string) => string; 50 35 }) { 51 - const [chartData, setChartData] = useState<Array<{x: number, y: number}>>([]); 52 36 const sensorData = minMax?.sensors?.[field]; 53 37 54 - // Fetch daily chart data for temperature 55 - useEffect(() => { 56 - const fetchChartData = async () => { 57 - try { 58 - const response = await fetch(`${API_ENDPOINTS.DATA_DAILY_CHART}?sensor=${field}&type=temperature&resolution=minute`); 59 - if (response.ok) { 60 - const data = await response.json(); 61 - if (data.ok && data.data) { 62 - setChartData(data.data); 63 - } 64 - } 65 - } catch (error) { 66 - console.error('Error fetching temperature chart data:', error); 67 - } 68 - }; 69 - 70 - if (currentTemp.value != null) { 71 - fetchChartData(); 72 - } 73 - }, [field, currentTemp.value]); 74 - 75 38 const formatMinMax = (value: number, time: string, label: string, isMax: boolean) => { 76 39 const timeStr = time ? new Date(time).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : ''; 77 40 const colorClass = isMax ? 'text-red-600' : 'text-blue-600'; ··· 97 60 {sensorData?.max != null && formatMinMax(sensorData.max, sensorData.maxTime, 'Max', true)} 98 61 </div> 99 62 )} 100 - {chartData.length > 0 && ( 101 - <div className="mt-2"> 102 - <MiniChart 103 - data={chartData} 104 - type="temperature" 105 - unit={unit} 106 - minValue={sensorData?.min} 107 - maxValue={sensorData?.max} 108 - minTime={sensorData?.minTime} 109 - maxTime={sensorData?.maxTime} 110 - /> 111 - </div> 112 - )} 113 63 </div> 114 64 </div> 115 65 </div> 116 66 ); 117 67 } 118 68 119 - /** 120 - * A component to display a humidity value along with its daily min/max values. 121 - * @param props - The component props. 122 - * @returns A React component for displaying humidity with min/max data. 123 - * @private 124 - */ 125 69 function HumidityLabelValue({ 126 70 label, 127 71 currentHumidity, ··· 137 81 unit?: string; 138 82 t: (key: string) => string; 139 83 }) { 140 - const [chartData, setChartData] = useState<Array<{x: number, y: number}>>([]); 141 84 const sensorData = minMax?.humidity?.[field]; 142 85 143 - // Fetch daily chart data for humidity 144 - useEffect(() => { 145 - const fetchChartData = async () => { 146 - try { 147 - const response = await fetch(`${API_ENDPOINTS.DATA_DAILY_CHART}?sensor=${field}&type=humidity&resolution=minute`); 148 - if (response.ok) { 149 - const data = await response.json(); 150 - if (data.ok && data.data) { 151 - setChartData(data.data); 152 - } 153 - } 154 - } catch (error) { 155 - console.error('Error fetching humidity chart data:', error); 156 - } 157 - }; 158 - 159 - if (currentHumidity.value != null) { 160 - fetchChartData(); 161 - } 162 - }, [field, currentHumidity.value]); 163 - 164 86 const formatMinMax = (value: number, time: string, label: string, isMax: boolean) => { 165 87 const timeStr = time ? new Date(time).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : ''; 166 88 const colorClass = isMax ? 'text-red-600' : 'text-blue-600'; ··· 186 108 {sensorData?.max != null && formatMinMax(sensorData.max, sensorData.maxTime, 'Max', true)} 187 109 </div> 188 110 )} 189 - {chartData.length > 0 && ( 190 - <div className="mt-2"> 191 - <MiniChart 192 - data={chartData} 193 - type="humidity" 194 - unit={unit} 195 - minValue={sensorData?.min} 196 - maxValue={sensorData?.max} 197 - minTime={sensorData?.minTime} 198 - maxTime={sensorData?.maxTime} 199 - /> 200 - </div> 201 - )} 202 111 </div> 203 112 </div> 204 113 </div> 205 114 ); 206 115 } 207 116 208 - /** 209 - * Displays the current value of a sensor along with its daily minimum and maximum. 210 - * @param props - The component props. 211 - * @returns A React component showing the current value and min/max stats. 212 - * @private 213 - */ 214 117 function MinMaxDisplay({ 215 118 current, 216 119 minMax, ··· 250 153 ); 251 154 } 252 155 253 - /** 254 - * Safely reads a nested property from an object. 255 - * @param obj - The object to read from. 256 - * @param path - The dot-separated path to the property. 257 - * @returns The property value, or undefined if not found. 258 - * @private 259 - */ 260 156 function tryRead(obj: any, path: string): any { 261 157 return path.split(".").reduce((acc, key) => (acc && key in acc ? acc[key] : undefined), obj); 262 158 } 263 159 264 - /** 265 - * Safely extracts a numeric value from various possible data structures. 266 - * @param v - The value to parse. 267 - * @returns The numeric value, or null if parsing fails. 268 - * @private 269 - */ 270 160 function numVal(v: any): number | null { 271 161 if (v == null) return null; 272 162 if (typeof v === "number") return Number.isFinite(v) ? v : null; ··· 280 170 return null; 281 171 } 282 172 283 - /** 284 - * Calculates the dew point. 285 - * @param temperature - The temperature in Celsius. 286 - * @param humidity - The relative humidity in percent. 287 - * @returns The calculated dew point in Celsius. 288 - * @private 289 - */ 290 173 function calculateDewPoint(temperature: number, humidity: number): number { 291 174 // Magnus-Formel für Taupunktberechnung 292 175 const a = 17.27; ··· 298 181 return Number.isFinite(dewPoint) ? Math.round(dewPoint * 10) / 10 : temperature; 299 182 } 300 183 301 - /** 302 - * Calculates the heat index (apparent temperature). 303 - * @param temperature - The temperature in Celsius. 304 - * @param humidity - The relative humidity in percent. 305 - * @returns The calculated heat index in Celsius. 306 - * @private 307 - */ 308 184 function calculateHeatIndex(temperature: number, humidity: number): number { 309 185 // Vereinfachte Formel für den Wärmeindex (Heat Index) 310 186 if (temperature < 20) { ··· 333 209 return Number.isFinite(heatIndex) ? Math.round(heatIndex * 10) / 10 : temperature; 334 210 } 335 211 336 - /** 337 - * Extracts a value and its unit from a potential value-unit object. 338 - * @param v - The value object. 339 - * @returns An object containing the value and an optional unit. 340 - * @private 341 - */ 342 212 function valueAndUnit(v: any): { value: string | number | null; unit?: string } { 343 213 if (v == null) return { value: null }; 344 214 if (typeof v === "object" && ("value" in v)) { ··· 347 217 return { value: v }; 348 218 } 349 219 350 - /** 351 - * Formats a value-unit object into a display string. 352 - * @param vu - The value-unit object. 353 - * @param fallbackUnit - A fallback unit to use if the object doesn't specify one. 354 - * @returns A formatted string like "10 °C" or "—" if the value is null. 355 - * @private 356 - */ 357 220 function fmtVU(vu: { value: string | number | null; unit?: string }, fallbackUnit?: string) { 358 221 if (vu.value == null || vu.value === "") return "—"; 359 222 const unit = vu.unit ?? fallbackUnit ?? ""; 360 223 return `${vu.value}${unit ? ` ${unit}` : ""}`; 361 224 } 362 225 363 - /** 364 - * Formats a battery status value into a localized string ("OK" or "Low"). 365 - * @param v - The battery status value. 366 - * @param t - The translation function. 367 - * @returns The localized battery status. 368 - * @private 369 - */ 370 226 function fmtBattery(v: any, t: (key: string) => string) { 371 227 const vu = valueAndUnit(v); 372 228 if (vu.value == null || vu.value === "") return "—"; ··· 375 231 return n === 0 ? t('statuses.ok') : t('statuses.low'); 376 232 } 377 233 378 - /** 379 - * Gets a translated, human-readable label for a given data key. 380 - * @param key - The data key (e.g., "wind_speed"). 381 - * @param t - The translation function. 382 - * @returns The internationalized label. 383 - * @private 384 - */ 385 234 function i18nLabel(key: string, t: (key: string) => string): string { 386 235 const k = key.toLowerCase(); 387 236 const map: Record<string, string> = { ··· 401 250 return map[k] || key.replace(/_/g, " "); 402 251 } 403 252 404 - /** 405 - * The main component for displaying real-time weather data in a list format. 406 - * It fetches and displays current conditions, sensor data, astronomical information, 407 - * and battery statuses. 408 - * 409 - * @returns A React component that renders the real-time data view. 410 - */ 411 253 export default function Realtime() { 412 254 const { t, i18n } = useTranslation(); 413 255 const { data, error, loading, lastUpdated } = useRealtime();
+1 -13
src/constants.js
··· 1 - /** 2 - * A collection of API endpoint paths used throughout the application. 3 - * @property {string} RT_LAST - Endpoint for the last received real-time data. 4 - * @property {string} CONFIG_CHANNELS - Endpoint for channel configuration. 5 - * @property {string} DEVICE_INFO - Endpoint for device information (timezone, coordinates). 6 - * @property {string} TEMP_MINMAX - Endpoint to get today's min/max temperature data. 7 - * @property {string} TEMP_MINMAX_UPDATE - Endpoint to trigger an update of min/max data. 8 - * @property {string} DATA_MONTHS - Endpoint to get the list of available months with data. 9 - * @property {string} DATA_EXTENT - Endpoint to get the global time range of all data. 10 - * @property {string} DATA_ALLSENSORS - Endpoint for historical data from all channel sensors. 11 - * @property {string} DATA_MAIN - Endpoint for historical data from the main weather station sensors. 12 - */ 1 + // API Endpoints 13 2 export const API_ENDPOINTS = { 14 3 // Realtime data 15 4 RT_LAST: '/api/rt/last', ··· 29 18 DATA_EXTENT: '/api/data/extent', 30 19 DATA_ALLSENSORS: '/api/data/allsensors', 31 20 DATA_MAIN: '/api/data/main', 32 - DATA_DAILY_CHART: '/api/data/dailychart', 33 21 };
-254
src/lib/daily-chart.ts
··· 1 - import { getDuckConn } from './db/duckdb'; 2 - import { ensureAllsensorsParquetsInRange } from './db/ingest'; 3 - 4 - interface ChartDataPoint { 5 - x: number; // timestamp 6 - y: number; // value 7 - } 8 - 9 - export async function getDailyChartData(sensor: string, type: 'temperature' | 'humidity'): Promise<ChartDataPoint[]> { 10 - try { 11 - const conn = await getDuckConn(); 12 - 13 - // Get today's date range 14 - const today = new Date(); 15 - const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); 16 - const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000); 17 - 18 - // Get parquet files for today 19 - const parquetFiles = await ensureAllsensorsParquetsInRange(startOfDay, endOfDay); 20 - if (!parquetFiles.length) { 21 - console.warn('No parquet files found for today'); 22 - return []; 23 - } 24 - 25 - // Map sensor names to exact column names in allsensors data 26 - // Note: allsensors data only contains CH1-CH8 sensors, no indoor/outdoor 27 - const getSensorColumn = (sensor: string, type: 'temperature' | 'humidity'): string[] => { 28 - const candidates: string[] = []; 29 - 30 - if (sensor === 'indoor') { 31 - // Indoor sensors don't exist in allsensors data - map to CH1 as fallback 32 - if (type === 'temperature') { 33 - candidates.push('CH1 Temperature(℃)'); 34 - } else { 35 - candidates.push('CH1 Luftfeuchtigkeit(%)'); 36 - } 37 - } else if (sensor === 'outdoor') { 38 - // Outdoor sensors don't exist in allsensors data - map to CH2 as fallback 39 - if (type === 'temperature') { 40 - candidates.push('CH2 Temperature(℃)'); 41 - } else { 42 - candidates.push('CH2 Luftfeuchtigkeit(%)'); 43 - } 44 - } else if (sensor.match(/temp_and_humidity_ch(\d+)/)) { 45 - const chNum = sensor.match(/temp_and_humidity_ch(\d+)/)?.[1]; 46 - if (type === 'temperature') { 47 - // Exact German column names from the data 48 - candidates.push(`CH${chNum} Temperature(℃)`); 49 - } else { 50 - // Exact German column names from the data 51 - candidates.push(`CH${chNum} Luftfeuchtigkeit(%)`); 52 - } 53 - } 54 - 55 - return candidates; 56 - }; 57 - 58 - const columnCandidates = getSensorColumn(sensor, type); 59 - 60 - // Build union of all parquet files 61 - const unionSources = parquetFiles.map((p) => `SELECT * FROM read_parquet('${p.replace(/\\/g, "/")}')`).join("\nUNION ALL\n"); 62 - 63 - // First, get available columns to find the right one 64 - const first = parquetFiles[0].replace(/\\/g, "/"); 65 - const describeSql = `DESCRIBE SELECT * FROM read_parquet('${first}')`; 66 - const descReader = await conn.runAndReadAll(describeSql); 67 - const cols: any[] = descReader.getRowObjects(); 68 - const availableColumns = cols.map((r: any) => String(r.column_name || r.ColumnName || r.column || "")); 69 - 70 - // Find matching column 71 - let targetColumn = null; 72 - for (const candidate of columnCandidates) { 73 - const found = availableColumns.find(col => 74 - col.toLowerCase().includes(candidate.toLowerCase()) || 75 - candidate.toLowerCase().includes(col.toLowerCase()) 76 - ); 77 - if (found) { 78 - targetColumn = found; 79 - break; 80 - } 81 - } 82 - 83 - if (!targetColumn) { 84 - console.warn(`No matching column found for ${sensor} ${type}. Available columns:`, availableColumns); 85 - return []; 86 - } 87 - 88 - // Format dates for DuckDB 89 - const startStr = startOfDay.toISOString().replace('T', ' ').slice(0, 16); 90 - const endStr = endOfDay.toISOString().replace('T', ' ').slice(0, 16); 91 - 92 - // Query to get hourly averages for the day 93 - const query = ` 94 - WITH src AS ( 95 - ${unionSources} 96 - ), 97 - filt AS ( 98 - SELECT * FROM src 99 - WHERE ts IS NOT NULL 100 - AND ts >= strptime('${startStr}', '%Y-%m-%d %H:%M') 101 - AND ts < strptime('${endStr}', '%Y-%m-%d %H:%M') 102 - AND "${targetColumn}" IS NOT NULL 103 - ) 104 - SELECT 105 - EXTRACT(EPOCH FROM date_trunc('hour', ts)) * 1000 as x, 106 - AVG(CAST("${targetColumn}" AS DOUBLE)) as y 107 - FROM filt 108 - GROUP BY date_trunc('hour', ts) 109 - ORDER BY x 110 - `; 111 - 112 - const result = await conn.runAndReadAll(query); 113 - 114 - const data: ChartDataPoint[] = []; 115 - const rows = result.getRowObjects(); 116 - for (const row of rows) { 117 - const x = row.x as number; 118 - const y = row.y as number; 119 - if (x != null && y != null && isFinite(y)) { 120 - data.push({ x, y }); 121 - } 122 - } 123 - 124 - return data; 125 - 126 - } catch (error) { 127 - console.error(`Error fetching daily chart data for ${sensor} ${type}:`, error); 128 - return []; 129 - } 130 - } 131 - 132 - export async function getDailyChartDataMinute(sensor: string, type: 'temperature' | 'humidity'): Promise<ChartDataPoint[]> { 133 - try { 134 - const conn = await getDuckConn(); 135 - 136 - // Get today's date range 137 - const today = new Date(); 138 - const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); 139 - const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000); 140 - 141 - // Get parquet files for today 142 - const parquetFiles = await ensureAllsensorsParquetsInRange(startOfDay, endOfDay); 143 - if (!parquetFiles.length) { 144 - console.warn('No parquet files found for today'); 145 - return []; 146 - } 147 - 148 - // Map sensor names to exact column names in allsensors data 149 - // Note: allsensors data only contains CH1-CH8 sensors, no indoor/outdoor 150 - const getSensorColumn = (sensor: string, type: 'temperature' | 'humidity'): string[] => { 151 - const candidates: string[] = []; 152 - 153 - if (sensor === 'indoor') { 154 - // Indoor sensors don't exist in allsensors data - map to CH1 as fallback 155 - if (type === 'temperature') { 156 - candidates.push('CH1 Temperature(℃)'); 157 - } else { 158 - candidates.push('CH1 Luftfeuchtigkeit(%)'); 159 - } 160 - } else if (sensor === 'outdoor') { 161 - // Outdoor sensors don't exist in allsensors data - map to CH2 as fallback 162 - if (type === 'temperature') { 163 - candidates.push('CH2 Temperature(℃)'); 164 - } else { 165 - candidates.push('CH2 Luftfeuchtigkeit(%)'); 166 - } 167 - } else if (sensor.match(/temp_and_humidity_ch(\d+)/)) { 168 - const chNum = sensor.match(/temp_and_humidity_ch(\d+)/)?.[1]; 169 - if (type === 'temperature') { 170 - // Exact German column names from the data 171 - candidates.push(`CH${chNum} Temperature(℃)`); 172 - } else { 173 - // Exact German column names from the data 174 - candidates.push(`CH${chNum} Luftfeuchtigkeit(%)`); 175 - } 176 - } 177 - 178 - return candidates; 179 - }; 180 - 181 - const columnCandidates = getSensorColumn(sensor, type); 182 - 183 - // Build union of all parquet files 184 - const unionSources = parquetFiles.map((p) => `SELECT * FROM read_parquet('${p.replace(/\\/g, "/")}')`).join("\nUNION ALL\n"); 185 - 186 - // First, get available columns to find the right one 187 - const first = parquetFiles[0].replace(/\\/g, "/"); 188 - const describeSql = `DESCRIBE SELECT * FROM read_parquet('${first}')`; 189 - const descReader = await conn.runAndReadAll(describeSql); 190 - const cols: any[] = descReader.getRowObjects(); 191 - const availableColumns = cols.map((r: any) => String(r.column_name || r.ColumnName || r.column || "")); 192 - 193 - // Find matching column 194 - let targetColumn = null; 195 - for (const candidate of columnCandidates) { 196 - const found = availableColumns.find(col => 197 - col.toLowerCase().includes(candidate.toLowerCase()) || 198 - candidate.toLowerCase().includes(col.toLowerCase()) 199 - ); 200 - if (found) { 201 - targetColumn = found; 202 - break; 203 - } 204 - } 205 - 206 - if (!targetColumn) { 207 - console.warn(`No matching column found for ${sensor} ${type}. Available columns:`, availableColumns); 208 - return []; 209 - } 210 - 211 - // Format dates for DuckDB 212 - const startStr = startOfDay.toISOString().replace('T', ' ').slice(0, 16); 213 - const endStr = endOfDay.toISOString().replace('T', ' ').slice(0, 16); 214 - 215 - // Query to get 5-minute averages for the day 216 - const query = ` 217 - WITH src AS ( 218 - ${unionSources} 219 - ), 220 - filt AS ( 221 - SELECT * FROM src 222 - WHERE ts IS NOT NULL 223 - AND ts >= strptime('${startStr}', '%Y-%m-%d %H:%M') 224 - AND ts < strptime('${endStr}', '%Y-%m-%d %H:%M') 225 - AND "${targetColumn}" IS NOT NULL 226 - AND EXTRACT(MINUTE FROM ts) % 5 = 0 227 - ) 228 - SELECT 229 - EXTRACT(EPOCH FROM date_trunc('minute', ts)) * 1000 as x, 230 - AVG(CAST("${targetColumn}" AS DOUBLE)) as y 231 - FROM filt 232 - GROUP BY date_trunc('minute', ts) 233 - ORDER BY x 234 - `; 235 - 236 - const result = await conn.runAndReadAll(query); 237 - 238 - const data: ChartDataPoint[] = []; 239 - const rows = result.getRowObjects(); 240 - for (const row of rows) { 241 - const x = row.x as number; 242 - const y = row.y as number; 243 - if (x != null && y != null && isFinite(y)) { 244 - data.push({ x, y }); 245 - } 246 - } 247 - 248 - return data; 249 - 250 - } catch (error) { 251 - console.error(`Error fetching minute daily chart data for ${sensor} ${type}:`, error); 252 - return []; 253 - } 254 - }
-7
src/lib/temp-minmax.ts
··· 38 38 try { 39 39 if (fs.existsSync(DATA_FILE)) { 40 40 const content = fs.readFileSync(DATA_FILE, 'utf8'); 41 - 42 - // Check if file is empty or contains only whitespace 43 - if (!content.trim()) { 44 - console.warn('temp-minmax-data.json is empty, returning null'); 45 - return null; 46 - } 47 - 48 41 const data = JSON.parse(content); 49 42 const today = getTodayDate(); 50 43
+69 -69
temp-minmax-data.json
··· 1 1 { 2 - "date": "2025-09-11", 2 + "date": "2025-09-13", 3 3 "sensors": { 4 4 "indoor": { 5 - "min": 21.6, 6 - "max": 21.7, 7 - "minTime": "2025-09-11T17:45:13.078Z", 8 - "maxTime": "2025-09-11T17:40:12.721Z" 5 + "min": 21.5, 6 + "max": 22.1, 7 + "minTime": "2025-09-13T06:49:17.303Z", 8 + "maxTime": "2025-09-13T06:22:58.260Z" 9 9 }, 10 10 "outdoor": { 11 - "min": 16.8, 12 - "max": 16.8, 13 - "minTime": "2025-09-11T17:40:12.721Z", 14 - "maxTime": "2025-09-11T17:40:12.721Z" 11 + "min": 14.6, 12 + "max": 18.1, 13 + "minTime": "2025-09-13T06:10:09.646Z", 14 + "maxTime": "2025-09-13T06:49:17.303Z" 15 15 }, 16 16 "temp_and_humidity_ch1": { 17 - "min": 20.1, 18 - "max": 20.2, 19 - "minTime": "2025-09-11T17:45:13.078Z", 20 - "maxTime": "2025-09-11T17:40:12.721Z" 17 + "min": 19.3, 18 + "max": 19.3, 19 + "minTime": "2025-09-13T06:10:09.646Z", 20 + "maxTime": "2025-09-13T06:10:09.646Z" 21 21 }, 22 22 "temp_and_humidity_ch2": { 23 - "min": 22.2, 24 - "max": 22.2, 25 - "minTime": "2025-09-11T17:40:12.721Z", 26 - "maxTime": "2025-09-11T17:40:12.721Z" 23 + "min": 21.9, 24 + "max": 22, 25 + "minTime": "2025-09-13T06:10:09.646Z", 26 + "maxTime": "2025-09-13T06:17:56.035Z" 27 27 }, 28 28 "temp_and_humidity_ch3": { 29 - "min": 21.2, 30 - "max": 21.2, 31 - "minTime": "2025-09-11T17:40:12.721Z", 32 - "maxTime": "2025-09-11T17:40:12.721Z" 29 + "min": 19.6, 30 + "max": 19.7, 31 + "minTime": "2025-09-13T06:10:09.646Z", 32 + "maxTime": "2025-09-13T06:49:17.303Z" 33 33 }, 34 34 "temp_and_humidity_ch5": { 35 - "min": 22.9, 36 - "max": 22.9, 37 - "minTime": "2025-09-11T17:40:12.721Z", 38 - "maxTime": "2025-09-11T17:40:12.721Z" 35 + "min": 22.3, 36 + "max": 22.4, 37 + "minTime": "2025-09-13T06:10:09.646Z", 38 + "maxTime": "2025-09-13T06:53:08.822Z" 39 39 }, 40 40 "temp_and_humidity_ch6": { 41 - "min": 21.3, 42 - "max": 21.3, 43 - "minTime": "2025-09-11T17:40:12.721Z", 44 - "maxTime": "2025-09-11T17:40:12.721Z" 41 + "min": 21.1, 42 + "max": 21.2, 43 + "minTime": "2025-09-13T06:10:09.646Z", 44 + "maxTime": "2025-09-13T06:49:17.303Z" 45 45 }, 46 46 "temp_and_humidity_ch7": { 47 - "min": 18.9, 48 - "max": 19.1, 49 - "minTime": "2025-09-11T17:45:13.078Z", 50 - "maxTime": "2025-09-11T17:40:12.721Z" 47 + "min": 15.7, 48 + "max": 16.8, 49 + "minTime": "2025-09-13T06:10:09.646Z", 50 + "maxTime": "2025-09-13T06:22:58.260Z" 51 51 }, 52 52 "temp_and_humidity_ch8": { 53 - "min": 22.1, 54 - "max": 22.1, 55 - "minTime": "2025-09-11T17:40:12.721Z", 56 - "maxTime": "2025-09-11T17:40:12.721Z" 53 + "min": 22.6, 54 + "max": 22.7, 55 + "minTime": "2025-09-13T06:17:56.035Z", 56 + "maxTime": "2025-09-13T06:10:09.646Z" 57 57 } 58 58 }, 59 59 "humidity": { 60 60 "indoor": { 61 - "min": 67, 62 - "max": 67, 63 - "minTime": "2025-09-11T17:40:12.721Z", 64 - "maxTime": "2025-09-11T17:40:12.721Z" 61 + "min": 64, 62 + "max": 65, 63 + "minTime": "2025-09-13T06:49:17.303Z", 64 + "maxTime": "2025-09-13T06:10:09.646Z" 65 65 }, 66 66 "outdoor": { 67 - "min": 94, 68 - "max": 95, 69 - "minTime": "2025-09-11T17:40:12.721Z", 70 - "maxTime": "2025-09-11T17:45:13.078Z" 67 + "min": 85, 68 + "max": 99, 69 + "minTime": "2025-09-13T06:53:08.822Z", 70 + "maxTime": "2025-09-13T06:10:09.646Z" 71 71 }, 72 72 "temp_and_humidity_ch1": { 73 73 "min": 72, 74 74 "max": 72, 75 - "minTime": "2025-09-11T17:40:12.721Z", 76 - "maxTime": "2025-09-11T17:40:12.721Z" 75 + "minTime": "2025-09-13T06:10:09.646Z", 76 + "maxTime": "2025-09-13T06:10:09.646Z" 77 77 }, 78 78 "temp_and_humidity_ch2": { 79 - "min": 69, 80 - "max": 70, 81 - "minTime": "2025-09-11T17:45:13.078Z", 82 - "maxTime": "2025-09-11T17:40:12.721Z" 79 + "min": 65, 80 + "max": 67, 81 + "minTime": "2025-09-13T06:49:17.303Z", 82 + "maxTime": "2025-09-13T06:10:09.646Z" 83 83 }, 84 84 "temp_and_humidity_ch3": { 85 85 "min": 71, 86 86 "max": 71, 87 - "minTime": "2025-09-11T17:40:12.721Z", 88 - "maxTime": "2025-09-11T17:40:12.721Z" 87 + "minTime": "2025-09-13T06:10:09.646Z", 88 + "maxTime": "2025-09-13T06:10:09.646Z" 89 89 }, 90 90 "temp_and_humidity_ch5": { 91 - "min": 66, 92 - "max": 66, 93 - "minTime": "2025-09-11T17:40:12.721Z", 94 - "maxTime": "2025-09-11T17:40:12.721Z" 91 + "min": 65, 92 + "max": 65, 93 + "minTime": "2025-09-13T06:10:09.646Z", 94 + "maxTime": "2025-09-13T06:10:09.646Z" 95 95 }, 96 96 "temp_and_humidity_ch6": { 97 - "min": 72, 98 - "max": 72, 99 - "minTime": "2025-09-11T17:40:12.721Z", 100 - "maxTime": "2025-09-11T17:40:12.721Z" 97 + "min": 69, 98 + "max": 70, 99 + "minTime": "2025-09-13T06:10:09.646Z", 100 + "maxTime": "2025-09-13T06:17:56.035Z" 101 101 }, 102 102 "temp_and_humidity_ch7": { 103 - "min": 81, 104 - "max": 81, 105 - "minTime": "2025-09-11T17:40:12.721Z", 106 - "maxTime": "2025-09-11T17:40:12.721Z" 103 + "min": 96, 104 + "max": 98, 105 + "minTime": "2025-09-13T06:10:09.646Z", 106 + "maxTime": "2025-09-13T06:22:58.260Z" 107 107 }, 108 108 "temp_and_humidity_ch8": { 109 - "min": 67, 110 - "max": 67, 111 - "minTime": "2025-09-11T17:40:12.721Z", 112 - "maxTime": "2025-09-11T17:40:12.721Z" 109 + "min": 66, 110 + "max": 66, 111 + "minTime": "2025-09-13T06:10:09.646Z", 112 + "maxTime": "2025-09-13T06:10:09.646Z" 113 113 } 114 114 } 115 115 }