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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = `
|
||||
<div class="font-medium mb-1">${slot.hourStr}</div>
|
||||
<div>${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH${slot.pressureHpa ? ` \u00b7 ${slot.pressureHpa.toFixed(0)} hPa` : ""}</div>
|
||||
<div>${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH${slot.pressureHpa ? ` \u00b7 ${slot.pressureHpa.toFixed(0)} hPa` : ""}${uviStr}</div>
|
||||
<div class="capitalize">${slot.budgetStatus} \u00b7 ${esc(modeLabel)}</div>
|
||||
`;
|
||||
const hourActions = _hourActionMap && _hourActionMap[slot.hour];
|
||||
|
||||
66
web/js/db.js
66
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 || "",
|
||||
|
||||
@@ -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 = '<a href="https://openweathermap.org/api/one-call-3" target="_blank" rel="noopener" class="text-orange-600 hover:underline">Requires One Call 3.0 subscription \u2192</a>';
|
||||
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) => {
|
||||
|
||||
@@ -391,6 +391,7 @@
|
||||
<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>
|
||||
<p id="forecast-provider-desc" class="text-xs text-gray-500 dark:text-gray-400">{{t "setup.forecast.providerDesc.openmeteo"}}</p>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user