Files
wetter_station/frontend/src/App.jsx
T
admin b03bd8589f V 1.7.1 fix: Astro-Werte gegen USNO geprueft, Hoehenkorrektur entfernt
Die Pruefung in 1.7.0 lief gegen selbst geschriebene Referenzen desselben
Algorithmus — ein gemeinsamer Denkfehler faellt so nicht auf. Jetzt gegen die
Astronomical Applications API des U.S. Naval Observatory (JPL-Ephemeriden,
gleiche Grundlage wie der Astronomical Almanac), und dabei zwei Fehler
gefunden:

- Hoehenkorrektur -2.076*sqrt(547)/60 entfernt. Sie gilt fuer den Blick von
  einer Erhebung auf einen fernen Meereshorizont; die Station liegt in
  ringsum aehnlich hohem Gelaende. Sie hat die Zeiten um 5-6 min verschoben,
  weg von Almanach und USNO. Rest-Abweichung jetzt <= 45 s ueber 365 Tage.
- getMoonPhase() leitet die Zyklusposition aus der ekliptikalen Laengen-
  differenz Mond-Sonne ab statt aus dem Phasenwinkel. Das ist die Definition
  von Almanach und USNO; der Phasenwinkel laeuft davon ab, sobald der Mond
  weit vom Knoten steht. Neu-/Vollmond lagen dadurch bis zu 3,8 h daneben,
  jetzt <= 28 min. Die Viertel waren nie betroffen.

Belegt durch astro.test.js gegen ein eingechecktes Fixture (365 Tage plus 50
Phasenereignisse 2026). Der Test geht nicht ins Netz; neu erzeugen laesst er
sich mit scripts/fetch-usno-reference.mjs. Testrunner ist node:test, keine
neue Abhaengigkeit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:18:09 +02:00

255 lines
8.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react'
import WeatherDashboard from './components/WeatherDashboard'
import { getSunTimes, getMoonPhase, formatTime, formatMoonPhase } from './utils/astro'
import './App.css'
// Grad/Minuten/Sekunden in Dezimalgrad
const dms = (deg, min, sec) => deg + min / 60 + sec / 3600
// Dezimalgrad zurueck nach Grad/Minuten/Sekunden fuer die Anzeige
function formatDms(value) {
let deg = Math.floor(value)
let min = Math.floor((value - deg) * 60)
let sec = Math.round(((value - deg) * 60 - min) * 60)
if (sec === 60) { sec = 0; min += 1 } // Rundungsuebertrag
if (min === 60) { min = 0; deg += 1 }
return `${deg}° ${min} ${sec}`
}
// Standort der Wetterstation — Basis fuer Header-Anzeige und Astro-Berechnung
const STATION = {
lat: dms(48, 52, 27), // noerdliche Breite
lon: dms(9, 35, 46), // oestliche Laenge
elevation: 547, // Meter ueber NN — nur Anzeige, siehe ZENITH in astro.js
}
// API-Basis-URL: in Dev direkt auf Backend, in Prod ueber Nginx-Proxy
const API_BASE = import.meta.env.DEV ? 'http://localhost:8000' : '/api'
// 24-Stunden-URL fuer "Aktuell"-Anzeige (auch bei laengeren Zeitraeumen gebraucht)
const CURRENT_URL = `${API_BASE}/weather/history?hours=24&limit=5000`
// JSON-Fetch-Helfer: liefert {ok, data} oder wirft bei Netzfehler.
// Per signal kann der Request abgebrochen werden, wenn timeRange wechselt.
async function fetchJson(url, signal) {
const res = await fetch(url, { signal })
if (!res.ok) throw new Error(`HTTP ${res.status} bei ${url}`)
return res.json()
}
// Bestimmt die URLs fuer den gewaehlten Zeitbereich.
// Returns: { weatherUrl, rainUrl, needsCurrent }
function buildUrls(timeRange) {
// Custom-Range
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const start = encodeURIComponent(timeRange.start)
const end = encodeURIComponent(timeRange.end)
const days = timeRange.days || 1
const path = days >= 7 ? 'daily-aggregated-range' : 'hourly-aggregated-range'
return {
weatherUrl: `${API_BASE}/weather/${path}?start=${start}&end=${end}`,
rainUrl: days < 7 ? `${API_BASE}/weather/daily-aggregated-range?start=${start}&end=${end}` : null,
needsCurrent: true,
}
}
switch (timeRange) {
case '24h':
return {
weatherUrl: `${API_BASE}/weather/history?hours=24&limit=5000`,
rainUrl: null,
needsCurrent: false, // Hauptdaten SIND die aktuellen 24h-Daten
}
case '7d':
return {
weatherUrl: `${API_BASE}/weather/daily-with-minmax?days=7`,
rainUrl: `${API_BASE}/weather/rain-daily?days=7`,
needsCurrent: true,
}
case '30d':
return {
weatherUrl: `${API_BASE}/weather/daily-with-minmax?days=30`,
rainUrl: `${API_BASE}/weather/rain-daily?days=30`,
needsCurrent: true,
}
case '365d':
return {
weatherUrl: `${API_BASE}/weather/daily-aggregated?days=365`,
rainUrl: `${API_BASE}/weather/rain-weekly?days=365`,
needsCurrent: true,
}
default:
return {
weatherUrl: `${API_BASE}/weather/history?hours=24`,
rainUrl: null,
needsCurrent: false,
}
}
}
function App() {
const [weatherData, setWeatherData] = useState([])
const [currentWeatherData, setCurrentWeatherData] = 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')
const [showTable, setShowTable] = useState(false)
// Erster-Lade-Flag: nur beim allerersten Fetch zeigen wir den Spinner.
// Bei spaeteren Re-Fetches (Auto-Refresh, Time-Range-Wechsel) bleiben die
// alten Daten sichtbar, bis die neuen da sind — flackert weniger.
const isInitialLoadRef = useRef(true)
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(() => {
// Statische Daten: kein Fetch noetig
if (window.__WEATHER_DATA__ && timeRange === '24h') {
setWeatherData(window.__WEATHER_DATA__)
setCurrentWeatherData(window.__WEATHER_DATA__)
setRainData([])
setLastUpdate(new Date())
setLoading(false)
return
}
const controller = new AbortController()
const fetchData = async () => {
if (isInitialLoadRef.current) setLoading(true)
const { weatherUrl, rainUrl, needsCurrent } = buildUrls(timeRange)
// Alle drei Requests parallel starten (statt sequentiell wie vorher).
// allSettled, damit ein Fehler bei rain/current die Hauptdaten nicht blockiert.
const requests = [
fetchJson(weatherUrl, controller.signal), // [0] weather - Pflicht
needsCurrent ? fetchJson(CURRENT_URL, controller.signal) : null, // [1] current - optional
rainUrl ? fetchJson(rainUrl, controller.signal) : null, // [2] rain - optional
]
const results = await Promise.allSettled(requests.map(p => p ?? Promise.resolve(null)))
// AbortError ignorieren — passiert, wenn timeRange waehrend des Requests
// gewechselt hat. Der nachfolgende Effekt-Lauf macht den richtigen Fetch.
const aborted = results.some(
r => r.status === 'rejected' && r.reason?.name === 'AbortError'
)
if (aborted) return
// Hauptdaten-Fehler ist fatal; ohne die zeigen wir nichts an.
if (results[0].status === 'rejected') {
setError(results[0].reason?.message || 'Unbekannter Fehler')
setLoading(false)
isInitialLoadRef.current = false
return
}
const weatherResult = results[0].value
const currentResult = results[1].status === 'fulfilled' ? results[1].value : null
const rainResult = results[2].status === 'fulfilled' ? results[2].value : null
setError(null)
setWeatherData(weatherResult)
// Wenn 24h gewaehlt ist, sind weather und current dieselben Daten
setCurrentWeatherData(needsCurrent ? (currentResult ?? []) : weatherResult)
setRainData(rainResult ?? [])
setLastUpdate(new Date())
setLoading(false)
isInitialLoadRef.current = false
}
fetchData()
// Auto-Refresh nur bei 24h, nur wenn keine statischen Daten
let interval = null
if (!window.__WEATHER_DATA__ && timeRange === '24h') {
interval = setInterval(fetchData, 5 * 60 * 1000)
}
return () => {
controller.abort()
if (interval) clearInterval(interval)
}
}, [timeRange])
if (loading) {
return (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>Lade Wetterdaten...</p>
</div>
)
}
if (error) {
return (
<div className="error-container">
<h2>Fehler beim Laden der Daten</h2>
<p>{error}</p>
</div>
)
}
// Aktuelle Zeit formatieren
const now = new Date()
const dateStr = now.toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})
const timeStr = now.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit'
})
// Sonnenauf-/untergang fuer heute am Stationsstandort
const sunTimes = getSunTimes(now, STATION.lat, STATION.lon)
const sunrise = formatTime(sunTimes.sunrise)
const sunset = formatTime(sunTimes.sunset)
// Mondphase zum aktuellen Zeitpunkt
const moonPhase = formatMoonPhase(getMoonPhase(now))
return (
<div className="app">
<header className="app-header">
<h1>Aktuelle Wetterdaten</h1>
<div className="header-datetime">{dateStr} - {timeStr} Uhr</div>
<div className="header-spacer"></div>
<div className="header-coordinates">
{formatDms(STATION.lat)} N - {formatDms(STATION.lon)} E - {STATION.elevation} m NN
</div>
<div className="header-astro">
Sonnen-Aufgang: {sunrise} - Untergang: {sunset} &nbsp;&nbsp; Mond-Phase: {moonPhase}
</div>
</header>
<main className="app-main">
<WeatherDashboard
data={weatherData}
currentData={currentWeatherData}
rainData={rainData}
timeRange={timeRange}
onTimeRangeChange={handleTimeRangeChange}
showTable={showTable}
onToggleTable={() => setShowTable(v => !v)}
/>
</main>
</div>
)
}
export default App