b03bd8589f
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>
255 lines
8.7 KiB
React
255 lines
8.7 KiB
React
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} 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
|