diff --git a/api/main.py b/api/main.py index 8260e2e..c3323bf 100644 --- a/api/main.py +++ b/api/main.py @@ -514,6 +514,74 @@ async def get_weekly_rain_data( conn.close() +@app.get("/weather/hourly-aggregated-range", response_model=List[dict], tags=["Aggregated Data"]) +async def get_hourly_aggregated_range( + start: datetime = Query(..., description="Startdatum (ISO 8601)"), + end: datetime = Query(..., description="Enddatum (ISO 8601)") +): + """Gibt stündlich aggregierte Wetterdaten für einen bestimmten Zeitraum zurück""" + if start >= end: + raise HTTPException(status_code=400, detail="Startdatum muss vor Enddatum liegen") + + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute(""" + SELECT + date_trunc('hour', datetime) as datetime, + AVG(temperature)::float as temperature, + ROUND(AVG(humidity))::int as humidity, + AVG(pressure)::float as pressure, + AVG(wind_speed * 1.60934)::float as wind_speed, + MAX(wind_gust * 1.60934)::float as wind_gust, + AVG(wind_dir)::float as wind_dir + FROM weather_data + WHERE datetime BETWEEN %s AND %s + GROUP BY date_trunc('hour', datetime) + ORDER BY datetime ASC + """, (start, end)) + results = cursor.fetchall() + + return [dict(row) for row in results] + finally: + conn.close() + + +@app.get("/weather/daily-aggregated-range", response_model=List[dict], tags=["Aggregated Data"]) +async def get_daily_aggregated_range( + start: datetime = Query(..., description="Startdatum (ISO 8601)"), + end: datetime = Query(..., description="Enddatum (ISO 8601)") +): + """Gibt täglich aggregierte Wetterdaten mit Min/Max-Temperaturen für einen bestimmten Zeitraum zurück""" + if start >= end: + raise HTTPException(status_code=400, detail="Startdatum muss vor Enddatum liegen") + + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute(""" + SELECT + date_trunc('day', datetime) as datetime, + AVG(temperature)::float as temperature, + MIN(temperature)::float as min_temperature, + MAX(temperature)::float as max_temperature, + ROUND(AVG(humidity))::int as humidity, + AVG(pressure)::float as pressure, + AVG(wind_speed * 1.60934)::float as wind_speed, + MAX(wind_gust * 1.60934)::float as wind_gust, + AVG(wind_dir)::float as wind_dir + FROM weather_data + WHERE datetime BETWEEN %s AND %s + GROUP BY date_trunc('day', datetime) + ORDER BY datetime ASC + """, (start, end)) + 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 3793a96..5e08295 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,7 +9,19 @@ function App() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [lastUpdate, setLastUpdate] = useState(null) - const [timeRange, setTimeRange] = useState('24h') // '24h', '7d', '30d', '365d' + const [timeRange, setTimeRange] = useState('24h') // '24h', '7d', '30d', '365d', oder {type: 'custom', start, end, days} + + // Handler für Zeitbereich-Änderungen + const handleTimeRangeChange = (range, customParams) => { + if (range === 'custom' && customParams) { + const start = new Date(customParams.start) + const end = new Date(customParams.end) + const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + setTimeRange({ type: 'custom', start: customParams.start, end: customParams.end, days }) + } else { + setTimeRange(range) + } + } useEffect(() => { const fetchData = async () => { @@ -30,26 +42,44 @@ function App() { 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/daily-with-minmax?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` + // Benutzerdefinierter Zeitbereich + if (typeof timeRange === 'object' && timeRange.type === 'custom') { + const start = encodeURIComponent(timeRange.start) + const end = encodeURIComponent(timeRange.end) + const days = timeRange.days || 1 + + if (days >= 7) { + // >= 7 Tage: Tagesaggregation mit Min/Max verwenden + weatherUrl = `${baseUrl}/weather/daily-aggregated-range?start=${start}&end=${end}` + rainUrl = null // TODO: Regen-Aggregation für Range implementieren + } else { + // < 7 Tage: Stundenaggregation verwenden + weatherUrl = `${baseUrl}/weather/hourly-aggregated-range?start=${start}&end=${end}` rainUrl = null + } + } else { + // Vordefinierte Zeitbereiche + switch (timeRange) { + case '24h': + weatherUrl = `${baseUrl}/weather/history?hours=24` + rainUrl = null + break + case '7d': + weatherUrl = `${baseUrl}/weather/daily-with-minmax?days=7` + rainUrl = `${baseUrl}/weather/rain-daily?days=7` + break + case '30d': + weatherUrl = `${baseUrl}/weather/daily-with-minmax?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 @@ -155,7 +185,7 @@ function App() { currentData={currentWeatherData} rainData={rainData} timeRange={timeRange} - onTimeRangeChange={setTimeRange} + onTimeRangeChange={handleTimeRangeChange} /> diff --git a/frontend/src/components/WeatherDashboard.css b/frontend/src/components/WeatherDashboard.css index dc0782f..7cbe63f 100644 --- a/frontend/src/components/WeatherDashboard.css +++ b/frontend/src/components/WeatherDashboard.css @@ -246,3 +246,135 @@ display: none; } } + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background: white; + border-radius: 12px; + padding: 2rem; + max-width: 500px; + width: 90%; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.modal-content h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: #333; + font-size: 1.5rem; +} + +.modal-form { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group label { + font-weight: 600; + color: #333; + font-size: 0.95rem; +} + +.form-group input[type="datetime-local"] { + padding: 0.75rem; + border: 2px solid #ddd; + border-radius: 8px; + font-size: 1rem; + font-family: inherit; + transition: border-color 0.2s ease; +} + +.form-group input[type="datetime-local"]:focus { + outline: none; + border-color: #0066cc; +} + +.error-message { + padding: 0.75rem; + background: #fee; + border: 1px solid #fcc; + border-radius: 6px; + color: #c00; + font-size: 0.9rem; +} + +.modal-info { + padding: 0.75rem; + background: #f0f8ff; + border-radius: 6px; + font-size: 0.85rem; + color: #555; +} + +.modal-info p { + margin: 0.25rem 0; +} + +.modal-buttons { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + +.modal-buttons button { + flex: 1; + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-cancel { + background: #f5f5f5; + color: #666; +} + +.btn-cancel:hover { + background: #e0e0e0; +} + +.btn-apply { + background: #0066cc; + color: white; +} + +.btn-apply:hover { + background: #0052a3; +} + +@media (max-width: 768px) { + .modal-content { + padding: 1.5rem; + } + + .modal-content h2 { + font-size: 1.25rem; + } + + .modal-buttons { + flex-direction: column; + } +} diff --git a/frontend/src/components/WeatherDashboard.jsx b/frontend/src/components/WeatherDashboard.jsx index 9579dd3..5f4c0be 100644 --- a/frontend/src/components/WeatherDashboard.jsx +++ b/frontend/src/components/WeatherDashboard.jsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import Highcharts from 'highcharts' import HighchartsReact from 'highcharts-react-official' import { format } from 'date-fns' @@ -21,6 +21,86 @@ Highcharts.setOptions({ }) const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '24h', onTimeRangeChange }) => { + // State für benutzerdefinierten Zeitbereich + const [showCustomRangeModal, setShowCustomRangeModal] = useState(false) + const [customStartDate, setCustomStartDate] = useState('') + const [customEndDate, setCustomEndDate] = useState('') + const [customError, setCustomError] = useState('') + + // Handler für benutzerdefinierten Zeitbereich + const handleOpenCustomRange = () => { + // Versuche gespeicherten Zeitbereich zu laden + try { + const savedRange = localStorage.getItem('customTimeRange') + if (savedRange) { + const { start, end } = JSON.parse(savedRange) + setCustomStartDate(start) + setCustomEndDate(end) + } else { + // Setze Standardwerte (letzte 7 Tage) + const end = new Date() + const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000) + + setCustomStartDate(format(start, "yyyy-MM-dd'T'HH:mm")) + setCustomEndDate(format(end, "yyyy-MM-dd'T'HH:mm")) + } + } catch (e) { + // Bei Fehler: Standardwerte verwenden + const end = new Date() + const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000) + + setCustomStartDate(format(start, "yyyy-MM-dd'T'HH:mm")) + setCustomEndDate(format(end, "yyyy-MM-dd'T'HH:mm")) + } + + setCustomError('') + setShowCustomRangeModal(true) + } + + const handleApplyCustomRange = () => { + // Validierung + const start = new Date(customStartDate) + const end = new Date(customEndDate) + + if (!customStartDate || !customEndDate) { + setCustomError('Bitte Start- und Endzeit auswählen') + return + } + + const diffHours = (end - start) / (1000 * 60 * 60) + const diffDays = diffHours / 24 + + if (diffHours < 1) { + setCustomError('Endzeit muss mindestens 1 Stunde nach der Startzeit liegen') + return + } + + if (diffDays > 365) { + setCustomError('Maximaler Zeitraum ist 1 Jahr (365 Tage)') + return + } + + // Zeitbereich im localStorage speichern + try { + localStorage.setItem('customTimeRange', JSON.stringify({ + start: customStartDate, + end: customEndDate + })) + } catch (e) { + // Fehler beim Speichern ignorieren + console.warn('Konnte Zeitbereich nicht speichern:', e) + } + + // Anwenden + onTimeRangeChange('custom', { start: customStartDate, end: customEndDate }) + setShowCustomRangeModal(false) + } + + const handleCancelCustomRange = () => { + setShowCustomRangeModal(false) + setCustomError('') + } + // Daten vorbereiten und nach Zeit sortieren (älteste zuerst) const sortedData = useMemo(() => { return [...data].sort((a, b) => new Date(a.datetime) - new Date(b.datetime)) @@ -33,6 +113,11 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' // Zeitraum-Label const timeRangeLabel = useMemo(() => { + if (typeof timeRange === 'object' && timeRange.type === 'custom') { + const start = new Date(timeRange.start) + const end = new Date(timeRange.end) + return `${format(start, 'dd.MM.yyyy HH:mm', { locale: de })} - ${format(end, 'dd.MM.yyyy HH:mm', { locale: de })}` + } switch (timeRange) { case '24h': return 'Die letzten 24 Stunden' case '7d': return 'Die letzten 7 Tage' @@ -44,9 +129,18 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' // Aggregations-Zusatz für Chart-Titel const aggregationSuffix = useMemo(() => { + // Custom range: basierend auf days + if (typeof timeRange === 'object' && timeRange.type === 'custom') { + const days = timeRange.days || 1 + if (days >= 7) { + return ' (Tagesmittel)' + } else { + return ' (Stundenmittel)' + } + } + // Vordefinierte Bereiche switch (timeRange) { case '7d': - return ' (Stundenmittel)' case '30d': case '365d': return ' (Tagesmittel)' @@ -57,9 +151,17 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' // Spezieller Suffix für Temperatur bei 30d/365d const temperatureSuffix = useMemo(() => { + // Custom range: basierend auf days + if (typeof timeRange === 'object' && timeRange.type === 'custom') { + const days = timeRange.days || 1 + if (days >= 7) { + return ' (Tages-Min/Max)' + } + return '' + } + // Vordefinierte Bereiche switch (timeRange) { case '7d': - return ' (Stundenmittel)' case '30d': case '365d': return ' (Tages-Min/Max)' @@ -70,7 +172,17 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' // Spezieller Suffix für Windböen bei 30d/365d const windGustSuffix = useMemo(() => { + // Custom range: basierend auf days + if (typeof timeRange === 'object' && timeRange.type === 'custom') { + const days = timeRange.days || 1 + if (days >= 7) { + return ' (TagesMax)' + } + return '' + } + // Vordefinierte Bereiche switch (timeRange) { + case '7d': case '30d': case '365d': return ' (TagesMax)' @@ -81,6 +193,10 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' // Gemeinsame Chart-Optionen (angepasst an Zeitraum) const getCommonOptions = () => { + // Prüfe, ob es ein custom range ist + const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom' + const customDays = isCustomRange ? (timeRange.days || 1) : 0 + // X-Achsen-Konfiguration basierend auf Zeitraum let xAxisConfig = { type: 'datetime', @@ -91,35 +207,66 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' // Zeitspanne für X-Achse berechnen (für festen Zeitrahmen) const now = new Date().getTime() let xAxisMin, xAxisMax + let tooltipDateFormat = '%d.%m.%Y' - switch (timeRange) { - case '24h': - xAxisConfig.tickInterval = 4 * 3600 * 1000 // 4 Stunden - xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' } - xAxisMin = now - 24 * 3600 * 1000 - xAxisMax = now - break - case '7d': + if (isCustomRange) { + // Custom range Konfiguration - Min/Max aus Daten nehmen + if (customDays >= 7) { xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' } - xAxisMin = now - 7 * 24 * 3600 * 1000 - xAxisMax = now - break - case '30d': - xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' } - xAxisMin = now - 30 * 24 * 3600 * 1000 - xAxisMax = now - break - case '365d': - xAxisConfig.labels = { format: '{value:%b %Y}', align: 'center' } - // Bei 365d: Min/Max aus vorhandenen Daten berechnen - if (sortedData.length > 0) { - xAxisMin = new Date(sortedData[0].datetime).getTime() - xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime() - } else { - xAxisMin = null - xAxisMax = null - } - break + tooltipDateFormat = '%d.%m.%Y' + } else { + xAxisConfig.labels = { format: '{value:%d.%m %H:%M}', align: 'center' } + tooltipDateFormat = '%d.%m.%Y %H:%M' + } + // X-Achsen-Bereich aus den tatsächlichen Daten bestimmen + if (sortedData.length > 0) { + xAxisMin = new Date(sortedData[0].datetime).getTime() + xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime() + } else { + xAxisMin = null + xAxisMax = null + } + } else { + // Vordefinierte Bereiche + switch (timeRange) { + case '24h': + xAxisConfig.tickInterval = 4 * 3600 * 1000 // 4 Stunden + xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' } + xAxisMin = now - 24 * 3600 * 1000 + xAxisMax = now + tooltipDateFormat = '%d.%m.%Y %H:%M' + break + case '7d': + xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' } + xAxisMin = now - 7 * 24 * 3600 * 1000 + xAxisMax = now + tooltipDateFormat = '%d.%m.%Y - %Hh' + break + case '30d': + xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' } + xAxisMin = now - 30 * 24 * 3600 * 1000 + xAxisMax = now + tooltipDateFormat = '%d.%m.%Y' + break + case '365d': + xAxisConfig.labels = { format: '{value:%b %Y}', align: 'center' } + tooltipDateFormat = '%b %Y' + // Bei 365d: Min/Max aus vorhandenen Daten berechnen + if (sortedData.length > 0) { + xAxisMin = new Date(sortedData[0].datetime).getTime() + xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime() + } else { + xAxisMin = null + xAxisMax = null + } + break + default: + xAxisConfig.tickInterval = 4 * 3600 * 1000 + xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' } + xAxisMin = now - 24 * 3600 * 1000 + xAxisMax = now + tooltipDateFormat = '%d.%m.%Y %H:%M' + } } // Min/Max für X-Achse setzen @@ -147,7 +294,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' tooltip: { shared: true, crosshairs: true, - xDateFormat: timeRange === '24h' ? '%d.%m.%Y %H:%M' : (timeRange === '7d' ? '%d.%m.%Y - %Hh' : '%d.%m.%Y') + xDateFormat: tooltipDateFormat }, plotOptions: { series: { @@ -171,8 +318,13 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' // Temperatur Chart const temperatureOptions = useMemo(() => { - // Bei 30d und 365d: Min/Max-Temperaturen anzeigen - if (timeRange === '30d' || timeRange === '365d') { + // Prüfe, ob Min/Max-Temperaturen angezeigt werden sollen + const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom' + const customDays = isCustomRange ? (timeRange.days || 1) : 0 + const showMinMax = (timeRange === '7d' || timeRange === '30d' || timeRange === '365d') || (isCustomRange && customDays >= 7) + + // Bei 7d, 30d, 365d und custom >= 7 Tage: Min/Max-Temperaturen anzeigen + if (showMinMax) { const minTemps = sortedData.filter(item => item.min_temperature != null).map(item => item.min_temperature) const maxTemps = sortedData.filter(item => item.max_temperature != null).map(item => item.max_temperature) @@ -326,7 +478,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' valueSuffix: ' %' } }] - }), [sortedData]) + }), [sortedData, timeRange]) // Luftdruck Chart const pressureOptions = useMemo(() => { @@ -385,7 +537,7 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' } }] } - }, [sortedData]) + }, [sortedData, timeRange]) // Regen Chart (angepasst an Zeitraum) const rainOptions = useMemo(() => { @@ -452,8 +604,13 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' // Windgeschwindigkeit Chart const windSpeedOptions = useMemo(() => { - // Bei 365d nur Windgeschwindigkeit, keine Böen - const series = timeRange === '365d' + // Prüfe, ob Böen angezeigt werden sollen (nicht bei 365d oder custom >= 365 Tage) + const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom' + const customDays = isCustomRange ? (timeRange.days || 1) : 0 + const hideGusts = (timeRange === '365d') || (isCustomRange && customDays >= 365) + + // Bei 365d und custom >= 365 Tage: nur Windgeschwindigkeit, keine Böen + const series = hideGusts ? [{ name: 'Windgeschwindigkeit', data: sortedData @@ -588,7 +745,15 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' } // Zeitformat basierend auf Zeitraum - const timeFormat = timeRange === '24h' ? 'HH:mm' : 'dd.MM HH:mm' + const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom' + const customDays = isCustomRange ? (timeRange.days || 1) : 0 + let timeFormat = 'dd.MM HH:mm' + + if (isCustomRange) { + timeFormat = customDays < 7 ? 'HH:mm' : 'dd.MM HH:mm' + } else { + timeFormat = timeRange === '24h' ? 'HH:mm' : 'dd.MM HH:mm' + } // Temperatur const minTempItem = periodData.reduce((min, item) => @@ -662,6 +827,13 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' 365 Tage 365d + {/* Zeitraum-Beschreibung */} @@ -747,6 +919,55 @@ const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = ' + {/* Modal für benutzerdefinierten Zeitbereich */} + {showCustomRangeModal && ( +
+
e.stopPropagation()}> +

Benutzerdefinierten Zeitbereich wählen

+ +
+
+ + setCustomStartDate(e.target.value)} + /> +
+ +
+ + setCustomEndDate(e.target.value)} + /> +
+ + {customError && ( +
{customError}
+ )} + +
+

• Endzeit muss mindestens 1 Stunde nach der Startzeit liegen

+

• Maximaler Zeitraum: 1 Jahr (365 Tage)

+
+ +
+ + +
+
+
+
+ )} + {/* Footer */}