From c471c0e33ad9597eefb4d9f1471925e1925d44e3 Mon Sep 17 00:00:00 2001 From: rxf Date: Sun, 22 Mar 2026 20:09:44 +0100 Subject: [PATCH] **WIP** --- api/main.py | 121 +++++++- frontend/src/App.jsx | 85 ++++-- frontend/src/components/WeatherDashboard.css | 51 ++++ frontend/src/components/WeatherDashboard.jsx | 297 ++++++++++++------- 4 files changed, 426 insertions(+), 128 deletions(-) diff --git a/api/main.py b/api/main.py index 9f4718b..bb0c4b5 100644 --- a/api/main.py +++ b/api/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict from typing import List, Optional from datetime import datetime, timedelta import os @@ -47,6 +47,8 @@ app.add_middleware( # Pydantic Models class WeatherData(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int datetime: datetime temperature: Optional[float] = None @@ -59,9 +61,6 @@ class WeatherData(BaseModel): rain_rate: Optional[float] = None received_at: datetime - class Config: - from_attributes = True - class WeatherStats(BaseModel): avg_temperature: Optional[float] = None @@ -343,6 +342,120 @@ async def get_rain_data( conn.close() +@app.get("/weather/hourly-aggregated", response_model=List[WeatherData], tags=["Aggregated Data"]) +async def get_hourly_aggregated_data( + days: int = Query(7, ge=1, le=60, description="Anzahl Tage zurück (max 60)") +): + """Gibt stündlich aggregierte Wetterdaten zurück (Stundenmittel)""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute(""" + SELECT + 0 as id, + date_trunc('hour', datetime) as datetime, + AVG(temperature) as temperature, + ROUND(AVG(humidity)) as humidity, + AVG(pressure) as pressure, + AVG(wind_speed * 1.60934) as wind_speed, + MAX(wind_gust * 1.60934) as wind_gust, + AVG(wind_dir) as wind_dir, + AVG(rain) as rain, + AVG(rain_rate) as rain_rate, + MAX(received_at) as received_at + FROM weather_data + WHERE datetime >= NOW() - make_interval(days => %s) + GROUP BY date_trunc('hour', datetime) + ORDER BY datetime ASC + """, (days,)) + results = cursor.fetchall() + + return [dict(row) for row in results] + finally: + conn.close() + + +@app.get("/weather/daily-aggregated", response_model=List[WeatherData], tags=["Aggregated Data"]) +async def get_daily_aggregated_data( + days: int = Query(365, ge=1, le=730, description="Anzahl Tage zurück (max 730)") +): + """Gibt täglich aggregierte Wetterdaten zurück (Tagesmittel)""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute(""" + SELECT + 0 as id, + date_trunc('day', datetime) as datetime, + AVG(temperature) as temperature, + ROUND(AVG(humidity)) as humidity, + AVG(pressure) as pressure, + AVG(wind_speed * 1.60934) as wind_speed, + MAX(wind_gust * 1.60934) as wind_gust, + AVG(wind_dir) as wind_dir, + AVG(rain) as rain, + AVG(rain_rate) as rain_rate, + MAX(received_at) as received_at + FROM weather_data + WHERE datetime >= NOW() - make_interval(days => %s) + GROUP BY date_trunc('day', datetime) + ORDER BY datetime ASC + """, (days,)) + results = cursor.fetchall() + + return [dict(row) for row in results] + finally: + conn.close() + + +@app.get("/weather/rain-daily", response_model=List[dict], tags=["Aggregated Data"]) +async def get_daily_rain_data( + days: int = Query(30, ge=1, le=365, description="Anzahl Tage zurück") +): + """Gibt tägliche Regensummen zurück""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute(""" + SELECT + date_trunc('day', datetime) as date, + SUM(rain) as total_rain + FROM weather_data + WHERE datetime >= NOW() - make_interval(days => %s) + GROUP BY date_trunc('day', datetime) + ORDER BY date ASC + """, (days,)) + results = cursor.fetchall() + + return [dict(row) for row in results] + finally: + conn.close() + + +@app.get("/weather/rain-weekly", response_model=List[dict], tags=["Aggregated Data"]) +async def get_weekly_rain_data( + days: int = Query(365, ge=1, le=730, description="Anzahl Tage zurück") +): + """Gibt wöchentliche Regensummen zurück (Woche = Mo-So)""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute(""" + SELECT + date_trunc('week', datetime) as week_start, + SUM(rain) as total_rain + FROM weather_data + WHERE datetime >= NOW() - make_interval(days => %s) + GROUP BY date_trunc('week', datetime) + ORDER BY week_start ASC + """, (days,)) + results = cursor.fetchall() + + return [dict(row) for row in results] + finally: + conn.close() + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3e05056..4d04010 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,35 +4,73 @@ import './App.css' function App() { const [weatherData, setWeatherData] = useState([]) + const [rainData, setRainData] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [lastUpdate, setLastUpdate] = useState(null) + const [timeRange, setTimeRange] = useState('24h') // '24h', '7d', '30d', '365d' useEffect(() => { const fetchData = async () => { try { + setLoading(true) + // Prüfe ob eingebettete Daten vorhanden sind (statischer Build) - if (window.__WEATHER_DATA__) { + if (window.__WEATHER_DATA__ && timeRange === '24h') { setWeatherData(window.__WEATHER_DATA__) + setRainData([]) setLastUpdate(new Date()) setLoading(false) - } else { - // Development oder Production: Daten von API holen - // Im Development: localhost:8000 - // Im Production: /api/ (nginx proxy) - const apiUrl = import.meta.env.DEV - ? 'http://localhost:8000/weather/history?hours=24' - : '/api/weather/history?hours=24' - - const response = await fetch(apiUrl) - if (!response.ok) { - throw new Error('API-Fehler: ' + response.status) - } - const data = await response.json() - setWeatherData(data) - setLastUpdate(new Date()) - setLoading(false) + return } + + // API-URLs basierend auf Zeitraum + let weatherUrl, rainUrl + const baseUrl = import.meta.env.DEV ? 'http://localhost:8000' : '/api' + + switch (timeRange) { + case '24h': + weatherUrl = `${baseUrl}/weather/history?hours=24` + rainUrl = null + break + case '7d': + weatherUrl = `${baseUrl}/weather/hourly-aggregated?days=7` + rainUrl = `${baseUrl}/weather/rain-daily?days=7` + break + case '30d': + weatherUrl = `${baseUrl}/weather/hourly-aggregated?days=30` + rainUrl = `${baseUrl}/weather/rain-daily?days=30` + break + case '365d': + weatherUrl = `${baseUrl}/weather/daily-aggregated?days=365` + rainUrl = `${baseUrl}/weather/rain-weekly?days=365` + break + default: + weatherUrl = `${baseUrl}/weather/history?hours=24` + rainUrl = null + } + + // Wetterdaten laden + const weatherResponse = await fetch(weatherUrl) + if (!weatherResponse.ok) { + throw new Error('API-Fehler: ' + weatherResponse.status) + } + const weatherDataResult = await weatherResponse.json() + setWeatherData(weatherDataResult) + + // Regendaten laden (falls separater Endpunkt) + if (rainUrl) { + const rainResponse = await fetch(rainUrl) + if (rainResponse.ok) { + const rainDataResult = await rainResponse.json() + setRainData(rainDataResult) + } + } else { + setRainData([]) + } + + setLastUpdate(new Date()) + setLoading(false) } catch (err) { setError(err.message) setLoading(false) @@ -41,12 +79,12 @@ function App() { fetchData() - // Automatisches Update alle 5 Minuten (nur im Entwicklungsmodus) - if (!window.__WEATHER_DATA__) { + // Automatisches Update alle 5 Minuten (nur für 24h und ohne statische Daten) + if (!window.__WEATHER_DATA__ && timeRange === '24h') { const interval = setInterval(fetchData, 5 * 60 * 1000) return () => clearInterval(interval) } - }, []) + }, [timeRange]) if (loading) { return ( @@ -98,7 +136,12 @@ function App() {
- +
) diff --git a/frontend/src/components/WeatherDashboard.css b/frontend/src/components/WeatherDashboard.css index e8026f8..87f20cb 100644 --- a/frontend/src/components/WeatherDashboard.css +++ b/frontend/src/components/WeatherDashboard.css @@ -4,6 +4,48 @@ margin: 0 auto; } +.time-range-nav { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + justify-content: center; + flex-wrap: wrap; +} + +.time-range-nav button { + padding: 0.5rem 1.5rem; + background: white; + border: 2px solid #ddd; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + color: #333; + transition: all 0.2s ease; +} + +.time-range-nav button:hover { + background: #f5f5f5; + border-color: #0066cc; +} + +.time-range-nav button.active { + background: #0066cc; + border-color: #0066cc; + color: white; +} + +.time-range-label { + text-align: center; + font-size: 1.1rem; + font-weight: 600; + color: #333; + margin-bottom: 1.5rem; + padding: 0.5rem; + background: #f8f9fa; + border-radius: 8px; +} + .current-values { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); @@ -137,6 +179,15 @@ padding: 0 0.5rem; } + .time-range-nav button { + padding: 0.4rem 1rem; + font-size: 0.85rem; + } + + .time-range-label { + font-size: 1rem; + } + .version-short { display: inline; } diff --git a/frontend/src/components/WeatherDashboard.jsx b/frontend/src/components/WeatherDashboard.jsx index fe1c592..eca3208 100644 --- a/frontend/src/components/WeatherDashboard.jsx +++ b/frontend/src/components/WeatherDashboard.jsx @@ -20,63 +20,88 @@ Highcharts.setOptions({ } }) -const WeatherDashboard = ({ data }) => { +const WeatherDashboard = ({ data, rainData = [], timeRange = '24h', onTimeRangeChange }) => { // Daten vorbereiten und nach Zeit sortieren (älteste zuerst) const sortedData = useMemo(() => { return [...data].sort((a, b) => new Date(a.datetime) - new Date(b.datetime)) }, [data]) + + // Zeitraum-Label + const timeRangeLabel = useMemo(() => { + switch (timeRange) { + case '24h': return 'Die letzten 24 Stunden' + case '7d': return 'Die letzten 7 Tage' + case '30d': return 'Die letzten 30 Tage' + case '365d': return 'Die letzten 365 Tage' + default: return 'Die letzten 24 Stunden' + } + }, [timeRange]) - // Gemeinsame Chart-Optionen - const getCommonOptions = () => ({ - chart: { - height: '50%', - animation: false, - backgroundColor: 'transparent' - }, - accessibility: { - enabled: false - }, - credits: { - enabled: false - }, - title: { - text: null - }, - legend: { - enabled: false - }, - tooltip: { - shared: true, - crosshairs: true, - xDateFormat: '%d.%m.%Y %H:%M' - }, - plotOptions: { - series: { - marker: { - enabled: false, - states: { - hover: { - enabled: true, - radius: 5 + // Gemeinsame Chart-Optionen (angepasst an Zeitraum) + const getCommonOptions = () => { + // X-Achsen-Konfiguration basierend auf Zeitraum + let xAxisConfig = { + type: 'datetime', + gridLineWidth: 1, + gridLineColor: 'rgba(0, 0, 0, 0.1)' + } + + switch (timeRange) { + case '24h': + xAxisConfig.tickInterval = 4 * 3600 * 1000 // 4 Stunden + xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' } + break + case '7d': + case '30d': + xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' } + break + case '365d': + xAxisConfig.labels = { format: '{value:%b}', align: 'center' } + break + } + + return { + chart: { + height: '50%', + animation: false, + backgroundColor: 'transparent' + }, + accessibility: { + enabled: false + }, + credits: { + enabled: false + }, + title: { + text: null + }, + legend: { + enabled: false + }, + tooltip: { + shared: true, + crosshairs: true, + xDateFormat: timeRange === '24h' ? '%d.%m.%Y %H:%M' : '%d.%m.%Y' + }, + plotOptions: { + series: { + marker: { + enabled: false, + states: { + hover: { + enabled: true, + radius: 5 + } } } } - } - }, - xAxis: { - type: 'datetime', - tickInterval: 4 * 3600 * 1000, // 4 Stunden in Millisekunden - labels: { - format: '{value:%H:%M}', - align: 'center' }, - gridLineWidth: 1, - gridLineColor: 'rgba(0, 0, 0, 0.1)' - }, - yAxis: { - gridLineColor: 'rgba(0, 0, 0, 0.05)' + xAxis: xAxisConfig, + yAxis: { + gridLineColor: 'rgba(0, 0, 0, 0.05)' + } } - }) + } // Temperatur Chart const temperatureOptions = useMemo(() => { @@ -192,33 +217,68 @@ const WeatherDashboard = ({ data }) => { } }, [sortedData]) - // Regen Chart - const rainOptions = useMemo(() => ({ - ...getCommonOptions(), - yAxis: { - ...getCommonOptions().yAxis, - title: { text: 'Regen (mm) / Rate (mm/h)' } - }, - series: [{ - name: 'Regen', - data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain]), - color: 'rgb(54, 162, 235)', - fillColor: 'rgba(54, 162, 235, 0.3)', - type: 'area', - tooltip: { - valueSuffix: ' mm' - } - }, { - name: 'Regenrate', - data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain_rate]), - color: 'rgb(59, 130, 246)', - dashStyle: 'Dash', - type: 'line', - tooltip: { - valueSuffix: ' mm/h' - } - }] - }), [sortedData]) + // Regen Chart (angepasst an Zeitraum) + const rainOptions = useMemo(() => { + let series = [] + let yAxisTitle = 'Regen (mm) / Rate (mm/h)' + + if (timeRange === '24h') { + // 24h: Area Chart mit Regen und Regenrate + yAxisTitle = 'Regen (mm) / Rate (mm/h)' + series = [{ + name: 'Regen', + data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain]), + color: 'rgb(54, 162, 235)', + fillColor: 'rgba(54, 162, 235, 0.3)', + type: 'area', + tooltip: { + valueSuffix: ' mm' + } + }, { + name: 'Regenrate', + data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain_rate]), + color: 'rgb(59, 130, 246)', + dashStyle: 'Dash', + type: 'line', + tooltip: { + valueSuffix: ' mm/h' + } + }] + } else if (timeRange === '7d' || timeRange === '30d') { + // 7d/30d: Balkendiagramm mit täglichen Summen + yAxisTitle = 'Regen (mm pro Tag)' + series = [{ + name: 'Regen', + data: rainData.map(item => [new Date(item.date).getTime(), item.total_rain || 0]), + color: 'rgb(54, 162, 235)', + type: 'column', + tooltip: { + valueSuffix: ' mm' + } + }] + } else if (timeRange === '365d') { + // 365d: Balkendiagramm mit wöchentlichen Summen + yAxisTitle = 'Regen (mm pro Woche)' + series = [{ + name: 'Regen', + data: rainData.map(item => [new Date(item.week_start).getTime(), item.total_rain || 0]), + color: 'rgb(54, 162, 235)', + type: 'column', + tooltip: { + valueSuffix: ' mm' + } + }] + } + + return { + ...getCommonOptions(), + yAxis: { + ...getCommonOptions().yAxis, + title: { text: yAxisTitle } + }, + series + } + }, [sortedData, rainData, timeRange]) // Windgeschwindigkeit Chart const windSpeedOptions = useMemo(() => ({ @@ -311,66 +371,97 @@ const WeatherDashboard = ({ data }) => { // Aktuellste Werte für Übersicht const current = sortedData[sortedData.length - 1] || {} - // Berechne Min/Max für den aktuellen Tag - const todayStats = useMemo(() => { - const now = new Date() - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - - const todayData = sortedData.filter(item => { - const itemDate = new Date(item.datetime) - return itemDate >= todayStart - }) + // Berechne Min/Max für den gewählten Zeitraum + const periodStats = useMemo(() => { + // Für den gewählten Zeitraum alle Daten verwenden + const periodData = sortedData - if (todayData.length === 0) { + if (periodData.length === 0) { return { minTemp: null, maxTemp: null, minTempTime: null, maxTempTime: null, minHumidity: null, maxHumidity: null, minHumidityTime: null, maxHumidityTime: null, minPressure: null, maxPressure: null, minPressureTime: null, maxPressureTime: null } } + + // Zeitformat basierend auf Zeitraum + const timeFormat = timeRange === '24h' ? 'HH:mm' : 'dd.MM HH:mm' // Temperatur - const minTempItem = todayData.reduce((min, item) => + const minTempItem = periodData.reduce((min, item) => item.temperature != null && (min === null || item.temperature < min.temperature) ? item : min, null) - const maxTempItem = todayData.reduce((max, item) => + const maxTempItem = periodData.reduce((max, item) => item.temperature != null && (max === null || item.temperature > max.temperature) ? item : max, null) // Luftfeuchtigkeit - const minHumidityItem = todayData.reduce((min, item) => + const minHumidityItem = periodData.reduce((min, item) => item.humidity != null && (min === null || item.humidity < min.humidity) ? item : min, null) - const maxHumidityItem = todayData.reduce((max, item) => + const maxHumidityItem = periodData.reduce((max, item) => item.humidity != null && (max === null || item.humidity > max.humidity) ? item : max, null) // Luftdruck - const minPressureItem = todayData.reduce((min, item) => + const minPressureItem = periodData.reduce((min, item) => item.pressure != null && (min === null || item.pressure < min.pressure) ? item : min, null) - const maxPressureItem = todayData.reduce((max, item) => + const maxPressureItem = periodData.reduce((max, item) => item.pressure != null && (max === null || item.pressure > max.pressure) ? item : max, null) // Windgeschwindigkeit - const maxWindGustItem = todayData.reduce((max, item) => + const maxWindGustItem = periodData.reduce((max, item) => item.wind_gust != null && (max === null || item.wind_gust > max.wind_gust) ? item : max, null) return { minTemp: minTempItem?.temperature ?? null, maxTemp: maxTempItem?.temperature ?? null, - minTempTime: minTempItem ? format(new Date(minTempItem.datetime), 'HH:mm', { locale: de }) : null, - maxTempTime: maxTempItem ? format(new Date(maxTempItem.datetime), 'HH:mm', { locale: de }) : null, + minTempTime: minTempItem ? format(new Date(minTempItem.datetime), timeFormat, { locale: de }) : null, + maxTempTime: maxTempItem ? format(new Date(maxTempItem.datetime), timeFormat, { locale: de }) : null, minHumidity: minHumidityItem?.humidity ?? null, maxHumidity: maxHumidityItem?.humidity ?? null, - minHumidityTime: minHumidityItem ? format(new Date(minHumidityItem.datetime), 'HH:mm', { locale: de }) : null, - maxHumidityTime: maxHumidityItem ? format(new Date(maxHumidityItem.datetime), 'HH:mm', { locale: de }) : null, + minHumidityTime: minHumidityItem ? format(new Date(minHumidityItem.datetime), timeFormat, { locale: de }) : null, + maxHumidityTime: maxHumidityItem ? format(new Date(maxHumidityItem.datetime), timeFormat, { locale: de }) : null, minPressure: minPressureItem?.pressure ?? null, maxPressure: maxPressureItem?.pressure ?? null, - minPressureTime: minPressureItem ? format(new Date(minPressureItem.datetime), 'HH:mm', { locale: de }) : null, - maxPressureTime: maxPressureItem ? format(new Date(maxPressureItem.datetime), 'HH:mm', { locale: de }) : null, + minPressureTime: minPressureItem ? format(new Date(minPressureItem.datetime), timeFormat, { locale: de }) : null, + maxPressureTime: maxPressureItem ? format(new Date(maxPressureItem.datetime), timeFormat, { locale: de }) : null, maxWindGust: maxWindGustItem?.wind_gust ?? null, - maxWindGustTime: maxWindGustItem ? format(new Date(maxWindGustItem.datetime), 'HH:mm', { locale: de }) : null + maxWindGustTime: maxWindGustItem ? format(new Date(maxWindGustItem.datetime), timeFormat, { locale: de }) : null } - }, [sortedData]) + }, [sortedData, timeRange]) return (
+ {/* Navigation für Zeitraum-Auswahl */} +
+ + + + +
+ + {/* Zeitraum-Beschreibung */} +
+ {timeRangeLabel} +
+ {/* Charts Grid */}
@@ -379,7 +470,7 @@ const WeatherDashboard = ({ data }) => {
- Min: {todayStats.minTemp?.toFixed(1) || '-'}°C ({todayStats.minTempTime || '-'}) | Max: {todayStats.maxTemp?.toFixed(1) || '-'}°C ({todayStats.maxTempTime || '-'}) + Min: {periodStats.minTemp?.toFixed(1) || '-'}°C ({periodStats.minTempTime || '-'}) | Max: {periodStats.maxTemp?.toFixed(1) || '-'}°C ({periodStats.maxTempTime || '-'})
@@ -389,7 +480,7 @@ const WeatherDashboard = ({ data }) => {
- Min: {todayStats.minPressure?.toFixed(1) || '-'} hPa ({todayStats.minPressureTime || '-'}) | Max: {todayStats.maxPressure?.toFixed(1) || '-'} hPa ({todayStats.maxPressureTime || '-'}) + Min: {periodStats.minPressure?.toFixed(1) || '-'} hPa ({periodStats.minPressureTime || '-'}) | Max: {periodStats.maxPressure?.toFixed(1) || '-'} hPa ({periodStats.maxPressureTime || '-'})
@@ -399,7 +490,7 @@ const WeatherDashboard = ({ data }) => {
- Min: {todayStats.minHumidity || '-'}% ({todayStats.minHumidityTime || '-'}) | Max: {todayStats.maxHumidity || '-'}% ({todayStats.maxHumidityTime || '-'}) + Min: {periodStats.minHumidity || '-'}% ({periodStats.minHumidityTime || '-'}) | Max: {periodStats.maxHumidity || '-'}% ({periodStats.maxHumidityTime || '-'})
@@ -423,7 +514,7 @@ const WeatherDashboard = ({ data }) => {
- Max: {todayStats.maxWindGust?.toFixed(1) || '-'} km/h ({todayStats.maxWindGustTime || '-'}) + Max: {periodStats.maxWindGust?.toFixed(1) || '-'} km/h ({periodStats.maxWindGustTime || '-'})