diff --git a/internal/compute/compute.go b/internal/compute/compute.go index f0b27f7..52964ee 100644 --- a/internal/compute/compute.go +++ b/internal/compute/compute.go @@ -122,6 +122,7 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { cloudPct := 50.0 sunMin := 0.0 pressureHpa := 0.0 + uvIndex := 0.0 if i < len(dayForecasts) { if dayForecasts[i].CloudCoverPct != nil { cloudPct = *dayForecasts[i].CloudCoverPct @@ -132,6 +133,9 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { if dayForecasts[i].PressureHpa != nil { pressureHpa = *dayForecasts[i].PressureHpa } + if dayForecasts[i].UVIndex != nil { + uvIndex = *dayForecasts[i].UVIndex + } } budgets, worstStatus, worstMode := computeRoomBudgets(req, h.Hour, h.TempC, cloudPct, sunMin, toggles) @@ -144,6 +148,7 @@ func BuildDashboard(req ComputeRequest) (DashboardData, error) { TempC: h.TempC, HumidityPct: h.HumidityPct, PressureHpa: pressureHpa, + UVIndex: uvIndex, RiskLevel: dayRisk.Level.String(), BudgetStatus: worstStatus.String(), IndoorTempC: indoorTempC, diff --git a/internal/compute/compute_test.go b/internal/compute/compute_test.go index 9ae3e52..1aebdff 100644 --- a/internal/compute/compute_test.go +++ b/internal/compute/compute_test.go @@ -30,6 +30,17 @@ func makeForecasts(baseTime time.Time, temps []float64) []Forecast { return forecasts } +func makeForecastsWithUVI(baseTime time.Time, temps []float64, uvis []float64) []Forecast { + forecasts := makeForecasts(baseTime, temps) + for i := range forecasts { + if i < len(uvis) { + uv := uvis[i] + forecasts[i].UVIndex = &uv + } + } + return forecasts +} + func TestBuildDashboard_NoForecasts(t *testing.T) { req := ComputeRequest{ Profile: Profile{Name: "Test", Timezone: "Europe/Berlin"}, @@ -850,3 +861,40 @@ func TestBuildDashboard_MultipleRooms(t *testing.T) { t.Errorf("got %d room budgets, want 2", len(data.RoomBudgets)) } } + +func TestBuildDashboard_UVIndex(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + base := time.Date(2025, 7, 15, 0, 0, 0, 0, loc) + + temps := make([]float64, 24) + uvis := make([]float64, 24) + for i := range temps { + temps[i] = 30 + if i >= 10 && i <= 14 { + uvis[i] = 8.5 + } + } + + req := ComputeRequest{ + Profile: Profile{Name: "Test", Timezone: "UTC"}, + Forecasts: makeForecastsWithUVI(base, temps, uvis), + Toggles: map[string]bool{}, + Date: "2025-07-15", + } + + data, err := BuildDashboard(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(data.Timeline) != 24 { + t.Fatalf("got %d timeline slots, want 24", len(data.Timeline)) + } + // Hour 12 should have UVI 8.5 + if data.Timeline[12].UVIndex != 8.5 { + t.Errorf("Timeline[12].UVIndex = %v, want 8.5", data.Timeline[12].UVIndex) + } + // Hour 0 should have UVI 0 + if data.Timeline[0].UVIndex != 0 { + t.Errorf("Timeline[0].UVIndex = %v, want 0", data.Timeline[0].UVIndex) + } +} diff --git a/internal/compute/types.go b/internal/compute/types.go index b14e650..3c75096 100644 --- a/internal/compute/types.go +++ b/internal/compute/types.go @@ -99,6 +99,7 @@ type Forecast struct { SunshineMin *float64 `json:"sunshineMin"` ApparentTempC *float64 `json:"apparentTempC"` PressureHpa *float64 `json:"pressureHpa"` + UVIndex *float64 `json:"uvIndex"` } // Warning holds a weather warning sent from the client. @@ -172,6 +173,7 @@ type TimelineSlotData struct { TempC float64 `json:"tempC"` HumidityPct float64 `json:"humidityPct"` PressureHpa float64 `json:"pressureHpa"` + UVIndex float64 `json:"uvIndex"` RiskLevel string `json:"riskLevel"` BudgetStatus string `json:"budgetStatus"` IndoorTempC float64 `json:"indoorTempC"` diff --git a/internal/weather/openmeteo_test.go b/internal/weather/openmeteo_test.go index 3bd0ea8..9c4d05b 100644 --- a/internal/weather/openmeteo_test.go +++ b/internal/weather/openmeteo_test.go @@ -70,6 +70,16 @@ func TestOpenMeteoFetchForecast(t *testing.T) { if resp.Daily[0].TempMaxC != 35.5 { t.Errorf("Daily[0].TempMaxC = %v, want 35.5", resp.Daily[0].TempMaxC) } + + // Open-Meteo does not provide alerts — Warnings must be nil + if resp.Warnings != nil { + t.Errorf("Warnings = %v, want nil (Open-Meteo has no alerts)", resp.Warnings) + } + + // Open-Meteo does not provide hourly UVI — must be zero + if resp.Hourly[0].UVIndex != 0 { + t.Errorf("Hourly[0].UVIndex = %v, want 0 (Open-Meteo has no hourly UVI)", resp.Hourly[0].UVIndex) + } } func TestOpenMeteoServerError(t *testing.T) { diff --git a/internal/weather/openweathermap.go b/internal/weather/openweathermap.go index 361dd47..433e777 100644 --- a/internal/weather/openweathermap.go +++ b/internal/weather/openweathermap.go @@ -73,9 +73,19 @@ type owmDaily struct { UVI float64 `json:"uvi"` } +type owmAlert struct { + SenderName string `json:"sender_name"` + Event string `json:"event"` + Start int64 `json:"start"` + End int64 `json:"end"` + Description string `json:"description"` + Tags []string `json:"tags"` +} + type owmResponse struct { Hourly []owmHourly `json:"hourly"` Daily []owmDaily `json:"daily"` + Alerts []owmAlert `json:"alerts"` } func (o *OpenWeatherMap) FetchForecast(ctx context.Context, lat, lon float64, timezone string) (*ForecastResponse, error) { @@ -83,7 +93,7 @@ func (o *OpenWeatherMap) FetchForecast(ctx context.Context, lat, lon float64, ti return nil, fmt.Errorf("openweathermap: API key required") } - reqURL := fmt.Sprintf("%s?lat=%.4f&lon=%.4f&appid=%s&units=metric&exclude=minutely,alerts", + reqURL := fmt.Sprintf("%s?lat=%.4f&lon=%.4f&appid=%s&units=metric&exclude=minutely", o.baseURL, lat, lon, o.apiKey) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) @@ -125,6 +135,7 @@ func (o *OpenWeatherMap) FetchForecast(ctx context.Context, lat, lon float64, ti WindSpeedMs: h.WindSpeed, WindDirectionDeg: h.WindDeg, PressureHpa: h.Pressure, + UVIndex: h.UVI, SunshineMin: (1 - h.Clouds/100) * 60, } if h.Rain != nil { @@ -144,5 +155,33 @@ func (o *OpenWeatherMap) FetchForecast(ctx context.Context, lat, lon float64, ti }) } + for _, a := range raw.Alerts { + result.Warnings = append(result.Warnings, Warning{ + EventType: a.Event, + Severity: owmTagsToSeverity(a.Tags), + Headline: a.Event, + Description: a.Description, + Onset: time.Unix(a.Start, 0).In(loc), + Expires: time.Unix(a.End, 0).In(loc), + }) + } + return result, nil } + +func owmTagsToSeverity(tags []string) string { + for _, tag := range tags { + switch tag { + case "Extreme temperature value", "Extreme weather": + return "Extreme" + case "Flood", "Tornado", "Hurricane": + return "Severe" + case "Thunderstorm", "Wind", "Rain", "Snow": + return "Moderate" + } + } + if len(tags) > 0 { + return "Minor" + } + return "Minor" +} diff --git a/internal/weather/openweathermap_test.go b/internal/weather/openweathermap_test.go index d8277dc..4350a17 100644 --- a/internal/weather/openweathermap_test.go +++ b/internal/weather/openweathermap_test.go @@ -53,6 +53,16 @@ const owmTestJSON = `{ "wind_deg": 210, "uvi": 8.1 } + ], + "alerts": [ + { + "sender_name": "DWD", + "event": "Heat Warning", + "start": 1752616800, + "end": 1752660000, + "description": "Extreme heat expected in the region.", + "tags": ["Extreme temperature value"] + } ] }` @@ -120,11 +130,19 @@ func TestOpenWeatherMapFetchForecast(t *testing.T) { t.Errorf("Hourly[0].SunshineMin = %v, want 45", h0.SunshineMin) } + // UV index + if h0.UVIndex != 7.2 { + t.Errorf("Hourly[0].UVIndex = %v, want 7.2", h0.UVIndex) + } + // 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) } + if h1.UVIndex != 8.1 { + t.Errorf("Hourly[1].UVIndex = %v, want 8.1", h1.UVIndex) + } // Daily if len(resp.Daily) != 1 { @@ -146,6 +164,27 @@ func TestOpenWeatherMapFetchForecast(t *testing.T) { if d0.Sunset.IsZero() { t.Error("Daily[0].Sunset is zero") } + + // Warnings from alerts + if len(resp.Warnings) != 1 { + t.Fatalf("Warnings len = %d, want 1", len(resp.Warnings)) + } + w0 := resp.Warnings[0] + if w0.Headline != "Heat Warning" { + t.Errorf("Warnings[0].Headline = %q, want %q", w0.Headline, "Heat Warning") + } + if w0.Severity != "Extreme" { + t.Errorf("Warnings[0].Severity = %q, want %q", w0.Severity, "Extreme") + } + if w0.Description != "Extreme heat expected in the region." { + t.Errorf("Warnings[0].Description = %q", w0.Description) + } + if w0.Onset.IsZero() { + t.Error("Warnings[0].Onset is zero") + } + if w0.Expires.IsZero() { + t.Error("Warnings[0].Expires is zero") + } } func TestOpenWeatherMapMissingAPIKey(t *testing.T) { @@ -203,4 +242,36 @@ func TestOpenWeatherMapMissingOptionalFields(t *testing.T) { if resp.Hourly[0].DewPointC != 0 { t.Errorf("DewPointC = %v, want 0", resp.Hourly[0].DewPointC) } + if resp.Hourly[0].UVIndex != 0 { + t.Errorf("UVIndex = %v, want 0", resp.Hourly[0].UVIndex) + } + if resp.Warnings != nil { + t.Errorf("Warnings = %v, want nil", resp.Warnings) + } +} + +func TestOwmTagsToSeverity(t *testing.T) { + tests := []struct { + tags []string + want string + }{ + {[]string{"Extreme temperature value"}, "Extreme"}, + {[]string{"Extreme weather"}, "Extreme"}, + {[]string{"Flood"}, "Severe"}, + {[]string{"Tornado"}, "Severe"}, + {[]string{"Hurricane"}, "Severe"}, + {[]string{"Thunderstorm"}, "Moderate"}, + {[]string{"Wind"}, "Moderate"}, + {[]string{"Rain"}, "Moderate"}, + {[]string{"Snow"}, "Moderate"}, + {[]string{"Fog"}, "Minor"}, + {[]string{}, "Minor"}, + {nil, "Minor"}, + } + for _, tt := range tests { + got := owmTagsToSeverity(tt.tags) + if got != tt.want { + t.Errorf("owmTagsToSeverity(%v) = %q, want %q", tt.tags, got, tt.want) + } + } } diff --git a/internal/weather/types.go b/internal/weather/types.go index 55704e8..74759a3 100644 --- a/internal/weather/types.go +++ b/internal/weather/types.go @@ -16,6 +16,7 @@ type HourlyForecast struct { SunshineMin float64 ShortwaveRadW float64 PressureHpa float64 + UVIndex float64 IsDay bool Condition string } @@ -32,9 +33,10 @@ type DailyForecast struct { // ForecastResponse holds the full forecast response from a provider. type ForecastResponse struct { - Hourly []HourlyForecast - Daily []DailyForecast - Source string + Hourly []HourlyForecast + Daily []DailyForecast + Warnings []Warning + Source string } // Warning represents a weather warning. diff --git a/web/i18n/de.json b/web/i18n/de.json index 8cc183c..651442f 100644 --- a/web/i18n/de.json +++ b/web/i18n/de.json @@ -155,7 +155,11 @@ "fetch": "Vorhersage abrufen", "lastFetched": "Zuletzt abgerufen", "never": "Nie", - "fetching": "Vorhersage wird abgerufen\u2026" + "fetching": "Vorhersage wird abgerufen\u2026", + "providerDesc": { + "openmeteo": "Kostenlos, kein API-Schl\u00fcssel n\u00f6tig. 3-Tage-St\u00fcndliche Vorhersage (Temp., Feuchte, Wind, Solar, Druck). Warnungen \u00fcber DWD (nur Deutschland).", + "openweathermap": "Erfordert One Call 3.0 Abonnement. 48h st\u00fcndlich + 8-Tage-Tagesvorhersage. Inklusive UV-Index und weltweite Wetterwarnungen." + } }, "windows": { "title": "Fenster", diff --git a/web/i18n/en.json b/web/i18n/en.json index 9903f7a..0a724f2 100644 --- a/web/i18n/en.json +++ b/web/i18n/en.json @@ -155,7 +155,11 @@ "fetch": "Fetch Forecast", "lastFetched": "Last fetched", "never": "Never", - "fetching": "Fetching forecast\u2026" + "fetching": "Fetching forecast\u2026", + "providerDesc": { + "openmeteo": "Free, no API key needed. 3-day hourly forecast (temp, humidity, wind, solar, pressure). Warnings via DWD (Germany only).", + "openweathermap": "Requires One Call 3.0 subscription. 48h hourly + 8-day daily forecast. Includes UV index and worldwide weather alerts." + } }, "windows": { "title": "Windows", diff --git a/web/js/dashboard.js b/web/js/dashboard.js index cc29b51..38f37d2 100644 --- a/web/js/dashboard.js +++ b/web/js/dashboard.js @@ -689,9 +689,10 @@ const idx = parseInt(cell.dataset.idx); const slot = timeline[idx]; const modeLabel = labels[slot.coolMode] || slot.coolMode || ""; + const uviStr = slot.uvIndex ? ` \u00b7 UV ${slot.uvIndex.toFixed(1)}` : ""; let tooltipHtml = `
${slot.hourStr}
-
${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH${slot.pressureHpa ? ` \u00b7 ${slot.pressureHpa.toFixed(0)} hPa` : ""}
+
${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH${slot.pressureHpa ? ` \u00b7 ${slot.pressureHpa.toFixed(0)} hPa` : ""}${uviStr}
${slot.budgetStatus} \u00b7 ${esc(modeLabel)}
`; const hourActions = _hourActionMap && _hourActionMap[slot.hour]; diff --git a/web/js/db.js b/web/js/db.js index 741fda4..38f9a17 100644 --- a/web/js/db.js +++ b/web/js/db.js @@ -255,34 +255,53 @@ async function fetchForecastForProfile(profileId) { sunshineMin: h.SunshineMin ?? h.sunshineMin ?? null, apparentTempC: h.ApparentTempC ?? h.apparentTempC ?? null, pressureHpa: h.PressureHpa ?? h.pressureHpa ?? null, + uvIndex: h.UVIndex ?? h.uvIndex ?? null, }); } - // Fetch warnings (optional — don't fail if this errors) + // Warnings: use provider-supplied warnings if available, otherwise fall back to DWD let warningCount = 0; - try { - const wResp = await fetch("/api/weather/warnings", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ lat: profile.latitude, lon: profile.longitude }), - }); - if (wResp.ok) { - const wData = await wResp.json(); - await deleteByIndex("warnings", "profileId", profileId); - for (const w of (wData.warnings || [])) { - await dbAdd("warnings", { - profileId, - headline: w.Headline || w.headline || "", - severity: w.Severity || w.severity || "", - description: w.Description || w.description || "", - instruction: w.Instruction || w.instruction || "", - onset: w.Onset || w.onset || "", - expires: w.Expires || w.expires || "", - }); - warningCount++; - } + const providerWarnings = data.Warnings || data.warnings || []; + if (providerWarnings.length > 0) { + await deleteByIndex("warnings", "profileId", profileId); + for (const w of providerWarnings) { + await dbAdd("warnings", { + profileId, + headline: w.Headline || w.headline || "", + severity: w.Severity || w.severity || "", + description: w.Description || w.description || "", + instruction: w.Instruction || w.instruction || "", + onset: w.Onset || w.onset || "", + expires: w.Expires || w.expires || "", + }); + warningCount++; } - } catch (_) { /* warnings are optional */ } + } else { + // Fall back to DWD warnings (optional — don't fail if this errors) + try { + const wResp = await fetch("/api/weather/warnings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lat: profile.latitude, lon: profile.longitude }), + }); + if (wResp.ok) { + const wData = await wResp.json(); + await deleteByIndex("warnings", "profileId", profileId); + for (const w of (wData.warnings || [])) { + await dbAdd("warnings", { + profileId, + headline: w.Headline || w.headline || "", + severity: w.Severity || w.severity || "", + description: w.Description || w.description || "", + instruction: w.Instruction || w.instruction || "", + onset: w.Onset || w.onset || "", + expires: w.Expires || w.expires || "", + }); + warningCount++; + } + } + } catch (_) { /* warnings are optional */ } + } await setSetting("lastFetched", new Date().toISOString()); return { forecasts: hourly.length, warnings: warningCount }; @@ -416,6 +435,7 @@ async function getComputePayload(profileId, dateStr) { sunshineMin: f.sunshineMin ?? null, apparentTempC: f.apparentTempC ?? null, pressureHpa: f.pressureHpa ?? null, + uvIndex: f.uvIndex ?? null, })), warnings: warnings.map(w => ({ headline: w.headline || "", diff --git a/web/js/setup.js b/web/js/setup.js index 315db4a..d01d3ba 100644 --- a/web/js/setup.js +++ b/web/js/setup.js @@ -995,13 +995,19 @@ function toggleForecastApiKeyField(provider) { const group = document.getElementById("forecast-apikey-group"); const hint = document.getElementById("forecast-provider-hint"); + const desc = document.getElementById("forecast-provider-desc"); + const ts = window.HG && window.HG.t ? window.HG.t : {}; + const providerDesc = (ts.setup && ts.setup.forecast && ts.setup.forecast.providerDesc) || {}; if (provider === "openweathermap") { group.classList.remove("hidden"); hint.innerHTML = 'Requires One Call 3.0 subscription \u2192'; + if (desc) desc.textContent = providerDesc.openweathermap || "48h hourly + 8-day daily forecast. Includes UV index and worldwide weather alerts."; } else { group.classList.add("hidden"); hint.textContent = ""; + if (desc) desc.textContent = providerDesc.openmeteo || "Free, no API key needed. 3-day hourly forecast (temp, humidity, wind, solar, pressure). Warnings via DWD (Germany only)."; } + if (desc) desc.classList.remove("hidden"); } document.getElementById("forecast-provider-select").addEventListener("change", (e) => { diff --git a/web/templates/setup.html b/web/templates/setup.html index 9cb762d..b9a2f9a 100644 --- a/web/templates/setup.html +++ b/web/templates/setup.html @@ -391,6 +391,7 @@ +

{{t "setup.forecast.providerDesc.openmeteo"}}