From b30c0b5f36775f0b8252bd2caa204641fa699904 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Wed, 11 Feb 2026 01:02:48 +0100 Subject: [PATCH] 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. --- internal/server/api.go | 44 ++++- internal/weather/dwd_wfs.go | 2 +- internal/weather/openmeteo.go | 2 +- internal/weather/openweathermap.go | 148 +++++++++++++++++ internal/weather/openweathermap_test.go | 206 ++++++++++++++++++++++++ internal/weather/retry.go | 88 ++++++++++ internal/weather/retry_test.go | 203 +++++++++++++++++++++++ web/i18n/de.json | 12 ++ web/i18n/en.json | 12 ++ web/js/dashboard.js | 58 ++++++- web/js/db.js | 33 +++- web/js/setup.js | 34 ++++ web/templates/dashboard.html | 20 ++- web/templates/setup.html | 26 ++- 14 files changed, 871 insertions(+), 17 deletions(-) create mode 100644 internal/weather/openweathermap.go create mode 100644 internal/weather/openweathermap_test.go create mode 100644 internal/weather/retry.go create mode 100644 internal/weather/retry_test.go diff --git a/internal/server/api.go b/internal/server/api.go index 15c171f..ca9312a 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -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) +} diff --git a/internal/weather/dwd_wfs.go b/internal/weather/dwd_wfs.go index 037ac37..f723b60 100644 --- a/internal/weather/dwd_wfs.go +++ b/internal/weather/dwd_wfs.go @@ -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} } diff --git a/internal/weather/openmeteo.go b/internal/weather/openmeteo.go index 1b1d1bb..a1305a7 100644 --- a/internal/weather/openmeteo.go +++ b/internal/weather/openmeteo.go @@ -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} } diff --git a/internal/weather/openweathermap.go b/internal/weather/openweathermap.go new file mode 100644 index 0000000..361dd47 --- /dev/null +++ b/internal/weather/openweathermap.go @@ -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 +} diff --git a/internal/weather/openweathermap_test.go b/internal/weather/openweathermap_test.go new file mode 100644 index 0000000..d8277dc --- /dev/null +++ b/internal/weather/openweathermap_test.go @@ -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) + } +} diff --git a/internal/weather/retry.go b/internal/weather/retry.go new file mode 100644 index 0000000..a00c67d --- /dev/null +++ b/internal/weather/retry.go @@ -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, + }, + } +} diff --git a/internal/weather/retry_test.go b/internal/weather/retry_test.go new file mode 100644 index 0000000..0339d70 --- /dev/null +++ b/internal/weather/retry_test.go @@ -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 +} diff --git a/web/i18n/de.json b/web/i18n/de.json index 319a327..dbce5e9 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -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", diff --git a/web/i18n/en.json b/web/i18n/en.json index 3c8998d..3c15088 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -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", diff --git a/web/js/dashboard.js b/web/js/dashboard.js index 41ce6bc..cc29b51 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -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) { diff --git a/web/js/db.js b/web/js/db.js index 3b3c363..741fda4 100644 --- a/web/js/db.js +++ b/web/js/db.js @@ -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 diff --git a/web/js/setup.js b/web/js/setup.js index 2c41aa6..abe9585 100644 --- a/web/js/setup.js +++ b/web/js/setup.js @@ -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 = 'Get an API key \u2192'; + } 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(); diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html index 7b83a8a..6e64f63 100644 --- a/web/templates/dashboard.html +++ b/web/templates/dashboard.html @@ -33,11 +33,23 @@ + + + + + +