This commit is contained in:
rxf
2026-03-22 20:09:44 +01:00
parent 0b9d21c24c
commit c471c0e33a
4 changed files with 426 additions and 128 deletions

View File

@@ -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)

View File

@@ -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() {
</header>
<main className="app-main">
<WeatherDashboard data={weatherData} />
<WeatherDashboard
data={weatherData}
rainData={rainData}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
/>
</main>
</div>
)

View File

@@ -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;
}

View File

@@ -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 (
<div className="dashboard">
{/* Navigation für Zeitraum-Auswahl */}
<div className="time-range-nav">
<button
className={timeRange === '24h' ? 'active' : ''}
onClick={() => onTimeRangeChange('24h')}
>
24 Stunden
</button>
<button
className={timeRange === '7d' ? 'active' : ''}
onClick={() => onTimeRangeChange('7d')}
>
7 Tage
</button>
<button
className={timeRange === '30d' ? 'active' : ''}
onClick={() => onTimeRangeChange('30d')}
>
30 Tage
</button>
<button
className={timeRange === '365d' ? 'active' : ''}
onClick={() => onTimeRangeChange('365d')}
>
365 Tage
</button>
</div>
{/* Zeitraum-Beschreibung */}
<div className="time-range-label">
{timeRangeLabel}
</div>
{/* Charts Grid */}
<div className="charts-grid">
<div className="chart-container">
@@ -379,7 +470,7 @@ const WeatherDashboard = ({ data }) => {
<HighchartsReact highcharts={Highcharts} options={temperatureOptions} />
</div>
<div className="chart-stats">
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 || '-'})
</div>
</div>
@@ -389,7 +480,7 @@ const WeatherDashboard = ({ data }) => {
<HighchartsReact highcharts={Highcharts} options={pressureOptions} />
</div>
<div className="chart-stats">
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 || '-'})
</div>
</div>
@@ -399,7 +490,7 @@ const WeatherDashboard = ({ data }) => {
<HighchartsReact highcharts={Highcharts} options={humidityOptions} />
</div>
<div className="chart-stats">
Min: {todayStats.minHumidity || '-'}% ({todayStats.minHumidityTime || '-'}) | Max: {todayStats.maxHumidity || '-'}% ({todayStats.maxHumidityTime || '-'})
Min: {periodStats.minHumidity || '-'}% ({periodStats.minHumidityTime || '-'}) | Max: {periodStats.maxHumidity || '-'}% ({periodStats.maxHumidityTime || '-'})
</div>
</div>
@@ -423,7 +514,7 @@ const WeatherDashboard = ({ data }) => {
<HighchartsReact highcharts={Highcharts} options={windSpeedOptions} />
</div>
<div className="chart-stats">
Max: {todayStats.maxWindGust?.toFixed(1) || '-'} km/h ({todayStats.maxWindGustTime || '-'})
Max: {periodStats.maxWindGust?.toFixed(1) || '-'} km/h ({periodStats.maxWindGustTime || '-'})
</div>
</div>