feat: add OWM alerts, UV index support, and provider info UI

- Parse OWM One Call 3.0 weather alerts and map to Warning structs
- Map hourly UVI from OWM response to HourlyForecast.UVIndex
- Add severity helper mapping OWM alert tags to severity levels
- Extract UVIndex through compute layer to timeline slots
- Smart warnings: use provider-supplied alerts, fall back to DWD
- Show UV index in dashboard timeline tooltips
- Add provider description below forecast dropdown with i18n
This commit is contained in:
2026-02-11 01:56:09 +01:00
parent 41649380e2
commit 201e5441cb
13 changed files with 243 additions and 30 deletions

View File

@@ -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,

View File

@@ -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)
}
}

View File

@@ -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"`

View File

@@ -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) {

View File

@@ -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"
}

View File

@@ -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)
}
}
}

View File

@@ -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.