feat: add OpenWeatherMap provider, retry transport, and forecast config UI

Add OpenWeatherMap One Call API 3.0 as alternative weather provider with
configurable selection in the Forecast tab. Includes server-side retry
transport with exponential backoff for all weather providers, structured
error responses with type classification, auto-fetch on stale dashboard
data, and improved error UX with specific messages.
This commit is contained in:
2026-02-11 01:02:48 +01:00
parent 4a119a6dde
commit b30c0b5f36
14 changed files with 871 additions and 17 deletions

View File

@@ -3,6 +3,8 @@ package server
import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"time"
@@ -36,6 +38,8 @@ type forecastRequest struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
Timezone string `json:"timezone"`
Provider string `json:"provider,omitempty"`
APIKey string `json:"apiKey,omitempty"`
}
func (s *Server) handleWeatherForecast(w http.ResponseWriter, r *http.Request) {
@@ -50,13 +54,24 @@ func (s *Server) handleWeatherForecast(w http.ResponseWriter, r *http.Request) {
return
}
var provider weather.Provider
switch req.Provider {
case "openweathermap":
if req.APIKey == "" {
jsonError(w, "API key required for OpenWeatherMap", http.StatusBadRequest)
return
}
provider = weather.NewOpenWeatherMap(req.APIKey, nil)
default:
provider = weather.NewOpenMeteo(nil)
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
provider := weather.NewOpenMeteo(nil)
resp, err := provider.FetchForecast(ctx, req.Lat, req.Lon, req.Timezone)
if err != nil {
jsonError(w, err.Error(), http.StatusBadGateway)
classifyWeatherError(w, err)
return
}
@@ -86,7 +101,7 @@ func (s *Server) handleWeatherWarnings(w http.ResponseWriter, r *http.Request) {
provider := weather.NewDWDWFS(nil)
warnings, err := provider.FetchWarnings(ctx, req.Lat, req.Lon)
if err != nil {
jsonError(w, err.Error(), http.StatusBadGateway)
classifyWeatherError(w, err)
return
}
@@ -340,3 +355,26 @@ func jsonError(w http.ResponseWriter, msg string, status int) {
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func jsonErrorTyped(w http.ResponseWriter, msg, errType string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg, "type": errType})
}
func classifyWeatherError(w http.ResponseWriter, err error) {
if errors.Is(err, context.DeadlineExceeded) {
jsonErrorTyped(w, err.Error(), "timeout", http.StatusGatewayTimeout)
return
}
var netErr net.Error
if errors.As(err, &netErr) {
if netErr.Timeout() {
jsonErrorTyped(w, err.Error(), "timeout", http.StatusGatewayTimeout)
return
}
jsonErrorTyped(w, err.Error(), "network", http.StatusBadGateway)
return
}
jsonErrorTyped(w, err.Error(), "upstream", http.StatusBadGateway)
}

View File

@@ -20,7 +20,7 @@ type DWDWFS struct {
// NewDWDWFS creates a new DWD WFS warning provider.
func NewDWDWFS(client *http.Client) *DWDWFS {
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
client = newRetryClient()
}
return &DWDWFS{client: client, baseURL: dwdWFSBaseURL}
}

View File

@@ -20,7 +20,7 @@ type OpenMeteo struct {
// NewOpenMeteo creates a new Open-Meteo provider.
func NewOpenMeteo(client *http.Client) *OpenMeteo {
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
client = newRetryClient()
}
return &OpenMeteo{client: client, baseURL: openMeteoBaseURL}
}

View File

@@ -0,0 +1,148 @@
package weather
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
const owmBaseURL = "https://api.openweathermap.org/data/3.0/onecall"
// OpenWeatherMap implements Provider using the OpenWeatherMap One Call API 3.0.
type OpenWeatherMap struct {
client *http.Client
baseURL string
apiKey string
}
// NewOpenWeatherMap creates a new OpenWeatherMap provider.
func NewOpenWeatherMap(apiKey string, client *http.Client) *OpenWeatherMap {
if client == nil {
client = newRetryClient()
}
return &OpenWeatherMap{client: client, baseURL: owmBaseURL, apiKey: apiKey}
}
func (o *OpenWeatherMap) Name() string { return "openweathermap" }
type owmHourlyRain struct {
OneH float64 `json:"1h"`
}
type owmHourly struct {
Dt int64 `json:"dt"`
Temp float64 `json:"temp"`
FeelsLike float64 `json:"feels_like"`
Pressure float64 `json:"pressure"`
Humidity float64 `json:"humidity"`
DewPoint float64 `json:"dew_point"`
UVI float64 `json:"uvi"`
Clouds float64 `json:"clouds"`
WindSpeed float64 `json:"wind_speed"`
WindDeg float64 `json:"wind_deg"`
Rain *owmHourlyRain `json:"rain,omitempty"`
}
type owmDailyTemp struct {
Day float64 `json:"day"`
Min float64 `json:"min"`
Max float64 `json:"max"`
}
type owmDailyFeelsLike struct {
Day float64 `json:"day"`
Night float64 `json:"night"`
Eve float64 `json:"eve"`
Morn float64 `json:"morn"`
}
type owmDaily struct {
Dt int64 `json:"dt"`
Sunrise int64 `json:"sunrise"`
Sunset int64 `json:"sunset"`
Temp owmDailyTemp `json:"temp"`
FeelsLike owmDailyFeelsLike `json:"feels_like"`
Pressure float64 `json:"pressure"`
Humidity float64 `json:"humidity"`
DewPoint float64 `json:"dew_point"`
Clouds float64 `json:"clouds"`
WindSpeed float64 `json:"wind_speed"`
WindDeg float64 `json:"wind_deg"`
UVI float64 `json:"uvi"`
}
type owmResponse struct {
Hourly []owmHourly `json:"hourly"`
Daily []owmDaily `json:"daily"`
}
func (o *OpenWeatherMap) FetchForecast(ctx context.Context, lat, lon float64, timezone string) (*ForecastResponse, error) {
if o.apiKey == "" {
return nil, fmt.Errorf("openweathermap: API key required")
}
reqURL := fmt.Sprintf("%s?lat=%.4f&lon=%.4f&appid=%s&units=metric&exclude=minutely,alerts",
o.baseURL, lat, lon, o.apiKey)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
resp, err := o.client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch openweathermap: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("openweathermap returned status %d", resp.StatusCode)
}
var raw owmResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, fmt.Errorf("decode openweathermap: %w", err)
}
loc, _ := time.LoadLocation(timezone)
if loc == nil {
loc = time.UTC
}
result := &ForecastResponse{Source: "openweathermap"}
for _, h := range raw.Hourly {
ts := time.Unix(h.Dt, 0).In(loc)
hf := HourlyForecast{
Timestamp: ts,
TemperatureC: h.Temp,
ApparentTempC: h.FeelsLike,
HumidityPct: h.Humidity,
DewPointC: h.DewPoint,
CloudCoverPct: h.Clouds,
WindSpeedMs: h.WindSpeed,
WindDirectionDeg: h.WindDeg,
PressureHpa: h.Pressure,
SunshineMin: (1 - h.Clouds/100) * 60,
}
if h.Rain != nil {
hf.PrecipitationMm = h.Rain.OneH
}
result.Hourly = append(result.Hourly, hf)
}
for _, d := range raw.Daily {
result.Daily = append(result.Daily, DailyForecast{
Date: time.Unix(d.Dt, 0).In(loc),
TempMaxC: d.Temp.Max,
TempMinC: d.Temp.Min,
ApparentTempMaxC: d.FeelsLike.Day,
Sunrise: time.Unix(d.Sunrise, 0).In(loc),
Sunset: time.Unix(d.Sunset, 0).In(loc),
})
}
return result, nil
}

View File

@@ -0,0 +1,206 @@
package weather
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
const owmTestJSON = `{
"lat": 52.52,
"lon": 13.41,
"timezone": "Europe/Berlin",
"hourly": [
{
"dt": 1752616800,
"temp": 28.5,
"feels_like": 30.1,
"pressure": 1015,
"humidity": 55,
"dew_point": 18.5,
"uvi": 7.2,
"clouds": 25,
"wind_speed": 4.2,
"wind_deg": 210,
"rain": {"1h": 0.5}
},
{
"dt": 1752620400,
"temp": 29.3,
"feels_like": 31.0,
"pressure": 1014,
"humidity": 52,
"dew_point": 18.0,
"uvi": 8.1,
"clouds": 10,
"wind_speed": 3.8,
"wind_deg": 220
}
],
"daily": [
{
"dt": 1752616800,
"sunrise": 1752544500,
"sunset": 1752601500,
"temp": {"day": 28.5, "min": 18.2, "max": 32.1},
"feels_like": {"day": 30.1, "night": 19.5, "eve": 28.0, "morn": 20.0},
"pressure": 1015,
"humidity": 55,
"dew_point": 18.5,
"clouds": 25,
"wind_speed": 4.2,
"wind_deg": 210,
"uvi": 8.1
}
]
}`
func TestOpenWeatherMapFetchForecast(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Verify query params
q := r.URL.Query()
if q.Get("appid") != "test-key" {
t.Errorf("missing or wrong appid: %s", q.Get("appid"))
}
if q.Get("units") != "metric" {
t.Errorf("expected units=metric, got %s", q.Get("units"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(owmTestJSON))
}))
defer srv.Close()
owm := NewOpenWeatherMap("test-key", srv.Client())
owm.baseURL = srv.URL
resp, err := owm.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
if err != nil {
t.Fatalf("FetchForecast: %v", err)
}
if resp.Source != "openweathermap" {
t.Errorf("Source = %s, want openweathermap", resp.Source)
}
// Hourly
if len(resp.Hourly) != 2 {
t.Fatalf("Hourly len = %d, want 2", len(resp.Hourly))
}
h0 := resp.Hourly[0]
if h0.TemperatureC != 28.5 {
t.Errorf("Hourly[0].TemperatureC = %v, want 28.5", h0.TemperatureC)
}
if h0.ApparentTempC != 30.1 {
t.Errorf("Hourly[0].ApparentTempC = %v, want 30.1", h0.ApparentTempC)
}
if h0.HumidityPct != 55 {
t.Errorf("Hourly[0].HumidityPct = %v, want 55", h0.HumidityPct)
}
if h0.DewPointC != 18.5 {
t.Errorf("Hourly[0].DewPointC = %v, want 18.5", h0.DewPointC)
}
if h0.CloudCoverPct != 25 {
t.Errorf("Hourly[0].CloudCoverPct = %v, want 25", h0.CloudCoverPct)
}
if h0.WindSpeedMs != 4.2 {
t.Errorf("Hourly[0].WindSpeedMs = %v, want 4.2", h0.WindSpeedMs)
}
if h0.WindDirectionDeg != 210 {
t.Errorf("Hourly[0].WindDirectionDeg = %v, want 210", h0.WindDirectionDeg)
}
if h0.PrecipitationMm != 0.5 {
t.Errorf("Hourly[0].PrecipitationMm = %v, want 0.5", h0.PrecipitationMm)
}
if h0.PressureHpa != 1015 {
t.Errorf("Hourly[0].PressureHpa = %v, want 1015", h0.PressureHpa)
}
// Sunshine derived from cloud cover: (1 - 25/100) * 60 = 45
if h0.SunshineMin != 45 {
t.Errorf("Hourly[0].SunshineMin = %v, want 45", h0.SunshineMin)
}
// Second hour: no rain field
h1 := resp.Hourly[1]
if h1.PrecipitationMm != 0 {
t.Errorf("Hourly[1].PrecipitationMm = %v, want 0 (missing field)", h1.PrecipitationMm)
}
// Daily
if len(resp.Daily) != 1 {
t.Fatalf("Daily len = %d, want 1", len(resp.Daily))
}
d0 := resp.Daily[0]
if d0.TempMaxC != 32.1 {
t.Errorf("Daily[0].TempMaxC = %v, want 32.1", d0.TempMaxC)
}
if d0.TempMinC != 18.2 {
t.Errorf("Daily[0].TempMinC = %v, want 18.2", d0.TempMinC)
}
if d0.ApparentTempMaxC != 30.1 {
t.Errorf("Daily[0].ApparentTempMaxC = %v, want 30.1", d0.ApparentTempMaxC)
}
if d0.Sunrise.IsZero() {
t.Error("Daily[0].Sunrise is zero")
}
if d0.Sunset.IsZero() {
t.Error("Daily[0].Sunset is zero")
}
}
func TestOpenWeatherMapMissingAPIKey(t *testing.T) {
owm := NewOpenWeatherMap("", nil)
_, err := owm.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
if err == nil {
t.Error("expected error for missing API key")
}
}
func TestOpenWeatherMapUnauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"cod":401,"message":"Invalid API key"}`))
}))
defer srv.Close()
owm := NewOpenWeatherMap("bad-key", srv.Client())
owm.baseURL = srv.URL
_, err := owm.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
if err == nil {
t.Error("expected error for 401 response")
}
}
func TestOpenWeatherMapMissingOptionalFields(t *testing.T) {
// Minimal response: hourly without rain, uvi, etc.
minimalJSON := `{
"hourly": [{"dt": 1752616800, "temp": 20.0, "humidity": 60, "clouds": 50, "wind_speed": 2.0, "wind_deg": 180, "pressure": 1010}],
"daily": [{"dt": 1752616800, "temp": {"max": 25.0, "min": 15.0}, "sunrise": 1752544500, "sunset": 1752601500}]
}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(minimalJSON))
}))
defer srv.Close()
owm := NewOpenWeatherMap("key", srv.Client())
owm.baseURL = srv.URL
resp, err := owm.FetchForecast(context.Background(), 52.52, 13.41, "Europe/Berlin")
if err != nil {
t.Fatalf("FetchForecast: %v", err)
}
if len(resp.Hourly) != 1 {
t.Fatalf("Hourly len = %d, want 1", len(resp.Hourly))
}
if resp.Hourly[0].TemperatureC != 20.0 {
t.Errorf("TemperatureC = %v, want 20.0", resp.Hourly[0].TemperatureC)
}
if resp.Hourly[0].PrecipitationMm != 0 {
t.Errorf("PrecipitationMm = %v, want 0", resp.Hourly[0].PrecipitationMm)
}
if resp.Hourly[0].DewPointC != 0 {
t.Errorf("DewPointC = %v, want 0", resp.Hourly[0].DewPointC)
}
}

88
internal/weather/retry.go Normal file
View File

@@ -0,0 +1,88 @@
package weather
import (
"io"
"net/http"
"time"
)
// RetryTransport wraps an http.RoundTripper with exponential backoff retries.
type RetryTransport struct {
Base http.RoundTripper
MaxRetries int
BaseDelay time.Duration
}
func (rt *RetryTransport) base() http.RoundTripper {
if rt.Base != nil {
return rt.Base
}
return http.DefaultTransport
}
func (rt *RetryTransport) maxRetries() int {
if rt.MaxRetries > 0 {
return rt.MaxRetries
}
return 3
}
func (rt *RetryTransport) baseDelay() time.Duration {
if rt.BaseDelay > 0 {
return rt.BaseDelay
}
return 500 * time.Millisecond
}
func (rt *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
var resp *http.Response
var err error
for attempt := 0; attempt <= rt.maxRetries(); attempt++ {
if attempt > 0 {
delay := rt.baseDelay() * (1 << (attempt - 1))
select {
case <-time.After(delay):
case <-req.Context().Done():
return nil, req.Context().Err()
}
}
resp, err = rt.base().RoundTrip(req)
if err != nil {
// Network error — retry
continue
}
if !shouldRetry(resp.StatusCode) || attempt == rt.maxRetries() {
return resp, nil
}
// Drain body before retry to reuse connection
if resp.Body != nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
return resp, err
}
func shouldRetry(statusCode int) bool {
if statusCode == 429 {
return true
}
return statusCode >= 500
}
// newRetryClient creates an http.Client with retry transport for provider use.
func newRetryClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
Transport: &RetryTransport{
Base: http.DefaultTransport,
MaxRetries: 3,
BaseDelay: 500 * time.Millisecond,
},
}
}

View File

@@ -0,0 +1,203 @@
package weather
import (
"context"
"errors"
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestRetryOn502(t *testing.T) {
var attempts int32
transport := &retryCountTransport{
attempts: &attempts,
responses: []int{502, 502, 200},
}
rt := &RetryTransport{
Base: transport,
MaxRetries: 3,
BaseDelay: time.Millisecond,
}
client := &http.Client{Transport: rt}
req, _ := http.NewRequest("GET", "http://example.com", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if atomic.LoadInt32(&attempts) != 3 {
t.Errorf("attempts = %d, want 3", atomic.LoadInt32(&attempts))
}
}
func TestRetryOnNetworkError(t *testing.T) {
var attempts int32
transport := &retryErrorTransport{
attempts: &attempts,
failCount: 2,
successResp: 200,
}
rt := &RetryTransport{
Base: transport,
MaxRetries: 3,
BaseDelay: time.Millisecond,
}
client := &http.Client{Transport: rt}
req, _ := http.NewRequest("GET", "http://example.com", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if atomic.LoadInt32(&attempts) != 3 {
t.Errorf("attempts = %d, want 3", atomic.LoadInt32(&attempts))
}
}
func TestRetryMaxRetriesExhausted(t *testing.T) {
var attempts int32
transport := &retryCountTransport{
attempts: &attempts,
responses: []int{502, 502, 502, 502},
}
rt := &RetryTransport{
Base: transport,
MaxRetries: 3,
BaseDelay: time.Millisecond,
}
client := &http.Client{Transport: rt}
req, _ := http.NewRequest("GET", "http://example.com", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
// Should return the last 502
if resp.StatusCode != 502 {
t.Errorf("status = %d, want 502", resp.StatusCode)
}
if atomic.LoadInt32(&attempts) != 4 { // 1 initial + 3 retries
t.Errorf("attempts = %d, want 4", atomic.LoadInt32(&attempts))
}
}
func TestNoRetryOn400(t *testing.T) {
var attempts int32
transport := &retryCountTransport{
attempts: &attempts,
responses: []int{400},
}
rt := &RetryTransport{
Base: transport,
MaxRetries: 3,
BaseDelay: time.Millisecond,
}
client := &http.Client{Transport: rt}
req, _ := http.NewRequest("GET", "http://example.com", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 400 {
t.Errorf("status = %d, want 400", resp.StatusCode)
}
if atomic.LoadInt32(&attempts) != 1 {
t.Errorf("attempts = %d, want 1 (no retry on 400)", atomic.LoadInt32(&attempts))
}
}
func TestRetryOn429(t *testing.T) {
var attempts int32
transport := &retryCountTransport{
attempts: &attempts,
responses: []int{429, 200},
}
rt := &RetryTransport{
Base: transport,
MaxRetries: 3,
BaseDelay: time.Millisecond,
}
client := &http.Client{Transport: rt}
req, _ := http.NewRequest("GET", "http://example.com", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if atomic.LoadInt32(&attempts) != 2 {
t.Errorf("attempts = %d, want 2", atomic.LoadInt32(&attempts))
}
}
func TestRetryRespectsContextCancellation(t *testing.T) {
var attempts int32
transport := &retryCountTransport{
attempts: &attempts,
responses: []int{502, 502, 502},
}
rt := &RetryTransport{
Base: transport,
MaxRetries: 3,
BaseDelay: 50 * time.Millisecond,
}
client := &http.Client{Transport: rt}
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
req, _ := http.NewRequestWithContext(ctx, "GET", "http://example.com", nil)
_, err := client.Do(req)
if err == nil {
t.Error("expected error from cancelled context")
}
}
// Test helpers
type retryCountTransport struct {
attempts *int32
responses []int
}
func (t *retryCountTransport) RoundTrip(req *http.Request) (*http.Response, error) {
idx := int(atomic.AddInt32(t.attempts, 1)) - 1
if idx >= len(t.responses) {
idx = len(t.responses) - 1
}
code := t.responses[idx]
return &http.Response{
StatusCode: code,
Body: io.NopCloser(strings.NewReader("")),
Header: make(http.Header),
}, nil
}
type retryErrorTransport struct {
attempts *int32
failCount int
successResp int
}
func (t *retryErrorTransport) RoundTrip(req *http.Request) (*http.Response, error) {
idx := int(atomic.AddInt32(t.attempts, 1)) - 1
if idx < t.failCount {
return nil, errors.New("connection refused")
}
return &http.Response{
StatusCode: t.successResp,
Body: io.NopCloser(strings.NewReader("")),
Header: make(http.Header),
}, nil
}

View File

@@ -145,6 +145,11 @@
"forecast": {
"title": "Wettervorhersage",
"help": "Wetterdaten f\u00fcr Ihren Profilstandort abrufen.",
"provider": "Wetteranbieter",
"free": "kostenlos, kein Schl\u00fcssel n\u00f6tig",
"apiKey": "API-Schl\u00fcssel",
"apiKeyPlaceholder": "OpenWeatherMap API-Schl\u00fcssel eingeben",
"saveConfig": "Einstellungen speichern",
"fetch": "Vorhersage abrufen",
"lastFetched": "Zuletzt abgerufen",
"never": "Nie",
@@ -194,6 +199,13 @@
"loading": "Laden\u2026",
"computing": "Hitzeanalyse wird berechnet\u2026",
"error": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
"retry": "Erneut versuchen",
"staleDataWarning": "Zwischengespeicherte Vorhersagedaten werden verwendet. Letzter Abruf fehlgeschlagen.",
"errorTimeout": "Wetterdienst hat nicht rechtzeitig geantwortet. Versuchen Sie es gleich erneut.",
"errorNetwork": "Wetterdienst nicht erreichbar. Pr\u00fcfen Sie Ihre Verbindung.",
"errorUpstream": "Wetterdienst hat einen Fehler zur\u00fcckgegeben. Versuchen Sie es sp\u00e4ter erneut.",
"errorUnknown": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.",
"autoFetching": "Vorhersage wird aktualisiert\u2026",
"internalGains": "Interne Gewinne",
"solarGain": "Solargewinn",
"ventGain": "L\u00fcftungsgewinn",

View File

@@ -145,6 +145,11 @@
"forecast": {
"title": "Forecast",
"help": "Fetch weather forecast data for your profile location.",
"provider": "Weather Provider",
"free": "free, no key needed",
"apiKey": "API Key",
"apiKeyPlaceholder": "Enter OpenWeatherMap API key",
"saveConfig": "Save Settings",
"fetch": "Fetch Forecast",
"lastFetched": "Last fetched",
"never": "Never",
@@ -194,6 +199,13 @@
"loading": "Loading\u2026",
"computing": "Computing heat analysis\u2026",
"error": "Failed to load data. Please try again.",
"retry": "Retry",
"staleDataWarning": "Using cached forecast data. Latest fetch failed.",
"errorTimeout": "Weather service timed out. Try again in a moment.",
"errorNetwork": "Could not reach weather service. Check your connection.",
"errorUpstream": "Weather service returned an error. Try again later.",
"errorUnknown": "Something went wrong. Please try again.",
"autoFetching": "Updating forecast\u2026",
"internalGains": "Internal Gains",
"solarGain": "Solar Gain",
"ventGain": "Ventilation Gain",

View File

@@ -84,10 +84,28 @@
window.matchMedia("(prefers-color-scheme: dark)").matches;
}
const STALE_THRESHOLD_MS = 3 * 60 * 60 * 1000; // 3 hours
function displayDetailedError(err) {
const ts = t();
const msgEl = $("error-message");
const hintEl = $("error-hint");
const typeMap = {
timeout: ts.errorTimeout || "Weather service timed out.",
network: ts.errorNetwork || "Could not reach weather service.",
upstream: ts.errorUpstream || "Weather service returned an error.",
};
const msg = (err && err.type && typeMap[err.type]) || ts.errorUnknown || "Something went wrong.";
if (msgEl) msgEl.textContent = msg;
if (hintEl) hintEl.textContent = err && err.message && err.message !== msg ? err.message : "";
}
window.loadDashboard = async function() {
hide("no-data");
hide("no-forecast");
hide("error-state");
hide("stale-warning");
hide("auto-fetch-indicator");
hide("data-display");
show("loading");
@@ -99,7 +117,41 @@
return;
}
const forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
let forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
// Auto-fetch if data is stale or missing
const lastFetched = await getSetting("lastFetched");
const isStale = !lastFetched || (Date.now() - new Date(lastFetched).getTime()) > STALE_THRESHOLD_MS;
if (isStale) {
if (forecasts.length > 0) {
// Have old data — auto-fetch silently in background
show("auto-fetch-indicator");
try {
await fetchForecastForProfile(profileId);
forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
} catch (fetchErr) {
// Old data exists — show stale warning, continue with old data
show("stale-warning");
}
hide("auto-fetch-indicator");
} else {
// No data at all — try to fetch
show("auto-fetch-indicator");
try {
await fetchForecastForProfile(profileId);
forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
} catch (fetchErr) {
hide("loading");
hide("auto-fetch-indicator");
displayDetailedError(fetchErr);
show("error-state");
return;
}
hide("auto-fetch-indicator");
}
}
if (forecasts.length === 0) {
hide("loading");
show("no-forecast");
@@ -239,6 +291,7 @@
} catch (err) {
hide("loading");
displayDetailedError(err);
show("error-state");
console.error("Dashboard error:", err);
}
@@ -813,6 +866,9 @@
const refreshBtn = $("refresh-forecast-btn");
if (refreshBtn) refreshBtn.addEventListener("click", refreshForecast);
const staleRetryBtn = $("stale-retry-btn");
if (staleRetryBtn) staleRetryBtn.addEventListener("click", refreshForecast);
// ========== Profile Switcher ==========
let _profileSwitcherInit = false;
async function initProfileSwitcher(activeId) {

View File

@@ -210,17 +210,36 @@ async function fetchForecastForProfile(profileId) {
const profile = profiles.find(p => p.id === profileId);
if (!profile) throw new Error("Profile not found");
// Fetch forecast
// Build forecast request with optional provider config
const forecastBody = {
lat: profile.latitude,
lon: profile.longitude,
timezone: profile.timezone || "Europe/Berlin",
};
const forecastProvider = await getSetting("forecastProvider");
const forecastApiKey = await getSetting("forecastApiKey");
if (forecastProvider) forecastBody.provider = forecastProvider;
if (forecastApiKey) forecastBody.apiKey = forecastApiKey;
const resp = await fetch("/api/weather/forecast", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
lat: profile.latitude,
lon: profile.longitude,
timezone: profile.timezone || "Europe/Berlin",
}),
body: JSON.stringify(forecastBody),
});
if (!resp.ok) throw new Error(await resp.text());
if (!resp.ok) {
let errMsg = "Forecast fetch failed";
let errType = "unknown";
try {
const errData = await resp.json();
errMsg = errData.error || errMsg;
errType = errData.type || errType;
} catch (_) {
errMsg = await resp.text();
}
const err = new Error(errMsg);
err.type = errType;
throw err;
}
const data = await resp.json();
// Clear old forecasts and store new ones

View File

@@ -984,6 +984,39 @@
});
// ========== Forecast ==========
async function loadForecastConfig() {
const provider = await getSetting("forecastProvider");
const apiKey = await getSetting("forecastApiKey");
if (provider) document.getElementById("forecast-provider-select").value = provider;
if (apiKey) document.getElementById("forecast-api-key").value = apiKey;
toggleForecastApiKeyField(provider);
}
function toggleForecastApiKeyField(provider) {
const group = document.getElementById("forecast-apikey-group");
const hint = document.getElementById("forecast-provider-hint");
if (provider === "openweathermap") {
group.classList.remove("hidden");
hint.innerHTML = '<a href="https://openweathermap.org/api" target="_blank" rel="noopener" class="text-orange-600 hover:underline">Get an API key \u2192</a>';
} else {
group.classList.add("hidden");
hint.textContent = "";
}
}
document.getElementById("forecast-provider-select").addEventListener("change", (e) => {
toggleForecastApiKeyField(e.target.value);
});
document.getElementById("forecast-config-form").addEventListener("submit", async (e) => {
e.preventDefault();
const provider = document.getElementById("forecast-provider-select").value;
const apiKey = document.getElementById("forecast-api-key").value;
await setSetting("forecastProvider", provider);
await setSetting("forecastApiKey", apiKey);
showToast("Forecast settings saved", false);
});
document.getElementById("fetch-forecast-btn").addEventListener("click", async () => {
const profileId = await getActiveProfileId();
if (!profileId) { showToast("Select a profile first", true); return; }
@@ -1151,6 +1184,7 @@
await loadOccupants();
await loadACUnits();
await loadToggles();
await loadForecastConfig();
await loadForecastStatus();
await loadLLMConfig();
await refreshRoomSelects();

View File

@@ -33,11 +33,23 @@
<!-- Error state -->
<div id="error-state" class="hidden">
<div class="text-center py-16">
<p class="text-red-600 dark:text-red-400 mb-4">{{t "dashboard.error"}}</p>
<button onclick="loadDashboard()" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition"></button>
<p id="error-message" class="text-red-600 dark:text-red-400 mb-2">{{t "dashboard.error"}}</p>
<p id="error-hint" class="text-sm text-gray-500 dark:text-gray-400 mb-4"></p>
<button onclick="loadDashboard()" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "dashboard.retry"}}</button>
</div>
</div>
<!-- Stale data warning banner -->
<div id="stale-warning" class="hidden bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-300 dark:border-yellow-700 rounded-lg px-4 py-3 mb-4 flex items-center justify-between">
<span class="text-sm text-yellow-700 dark:text-yellow-300">{{t "dashboard.staleDataWarning"}}</span>
<button id="stale-retry-btn" type="button" class="px-3 py-1 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition">{{t "dashboard.retry"}}</button>
</div>
<!-- Auto-fetch indicator -->
<div id="auto-fetch-indicator" class="hidden text-center py-2 text-sm text-gray-500 dark:text-gray-400">
<span class="inline-block animate-spin mr-1">&#x21bb;</span> {{t "dashboard.autoFetching"}}
</div>
<!-- Data display -->
<div id="data-display" class="hidden space-y-5">
<!-- Header -->
@@ -290,6 +302,10 @@
legendTemp: "{{t "dashboard.legendTemp"}}",
legendCooling: "{{t "dashboard.legendCooling"}}",
legendAI: "{{t "dashboard.legendAI"}}",
errorTimeout: "{{t "dashboard.errorTimeout"}}",
errorNetwork: "{{t "dashboard.errorNetwork"}}",
errorUpstream: "{{t "dashboard.errorUpstream"}}",
errorUnknown: "{{t "dashboard.errorUnknown"}}",
category: {
shading: "{{t "dashboard.category.shading"}}",
ventilation: "{{t "dashboard.category.ventilation"}}",

View File

@@ -375,7 +375,29 @@
<!-- Forecast tab -->
<section id="tab-forecast" class="tab-panel hidden">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.forecast.help"}}</p>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3">
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
<!-- Provider config -->
<form id="forecast-config-form" class="space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium mb-1">{{t "setup.forecast.provider"}}</label>
<select id="forecast-provider-select" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
<option value="openmeteo">Open-Meteo ({{t "setup.forecast.free"}})</option>
<option value="openweathermap">OpenWeatherMap</option>
</select>
</div>
<div id="forecast-apikey-group" class="hidden">
<label class="block text-sm font-medium mb-1">{{t "setup.forecast.apiKey"}}</label>
<input type="password" id="forecast-api-key" placeholder="{{t "setup.forecast.apiKeyPlaceholder"}}" class="w-full px-3 py-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
</div>
</div>
<div id="forecast-provider-hint" class="text-xs text-gray-400"></div>
<button type="submit" class="px-4 py-1.5 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition">{{t "setup.forecast.saveConfig"}}</button>
</form>
<hr class="border-gray-200 dark:border-gray-700">
<!-- Fetch button -->
<div class="flex items-center gap-4">
<button id="fetch-forecast-btn" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition text-sm">
{{t "setup.forecast.fetch"}}
@@ -385,7 +407,7 @@
</span>
</div>
<div id="forecast-spinner" class="hidden text-sm text-gray-500 dark:text-gray-400">
<span class="inline-block animate-spin mr-2"></span> {{t "setup.forecast.fetching"}}
<span class="inline-block animate-spin mr-2">&#x21bb;</span> {{t "setup.forecast.fetching"}}
</div>
</div>
</section>