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

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

View File

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

View File

@@ -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];

View File

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

View File

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

View File

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