Files
HeatGuard/web/js/db.js
vikingowl 201e5441cb 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
2026-02-11 01:56:09 +01:00

451 lines
16 KiB
JavaScript

// IndexedDB wrapper for HeatGuard
const DB_NAME = "heatguard";
const DB_VERSION = 2;
const STORES = {
profiles: { keyPath: "id", autoIncrement: true, indexes: [{ name: "name", keyPath: "name", unique: true }] },
rooms: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
devices: { keyPath: "id", autoIncrement: true, indexes: [{ name: "roomId", keyPath: "roomId" }] },
occupants: { keyPath: "id", autoIncrement: true, indexes: [{ name: "roomId", keyPath: "roomId" }] },
ac_units: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
ac_assignments: { keyPath: ["acId", "roomId"] },
forecasts: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
warnings: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId", keyPath: "profileId" }] },
toggles: { keyPath: "id", autoIncrement: true, indexes: [{ name: "profileId_name", keyPath: ["profileId", "name"] }] },
settings: { keyPath: "key" },
windows: { keyPath: "id", autoIncrement: true, indexes: [{ name: "roomId", keyPath: "roomId" }] },
};
let dbPromise = null;
function openDB() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
const tx = e.target.transaction;
for (const [name, cfg] of Object.entries(STORES)) {
if (!db.objectStoreNames.contains(name)) {
const opts = { keyPath: cfg.keyPath };
if (cfg.autoIncrement) opts.autoIncrement = true;
const store = db.createObjectStore(name, opts);
if (cfg.indexes) {
for (const idx of cfg.indexes) {
store.createIndex(idx.name, idx.keyPath, { unique: !!idx.unique });
}
}
}
}
// v1 → v2: migrate existing rooms to have a synthetic window each
if (e.oldVersion < 2 && db.objectStoreNames.contains("rooms") && db.objectStoreNames.contains("windows")) {
const roomStore = tx.objectStore("rooms");
const winStore = tx.objectStore("windows");
roomStore.openCursor().onsuccess = (ce) => {
const cursor = ce.target.result;
if (!cursor) return;
const r = cursor.value;
const area = (r.areaSqm || 15) * (r.windowFraction || 0.15);
winStore.add({
roomId: r.id,
orientation: r.orientation || "S",
areaSqm: Math.round(area * 100) / 100,
shgc: r.shgc || 0.6,
shadingType: r.shadingType || "none",
shadingFactor: r.shadingFactor ?? 1.0,
});
cursor.continue();
};
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbPromise;
}
async function dbPut(storeName, item) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, "readwrite");
const store = tx.objectStore(storeName);
const req = store.put(item);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function dbAdd(storeName, item) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, "readwrite");
const store = tx.objectStore(storeName);
const req = store.add(item);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function dbGet(storeName, key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, "readonly");
const req = tx.objectStore(storeName).get(key);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function dbGetAll(storeName) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, "readonly");
const req = tx.objectStore(storeName).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
async function dbGetByIndex(storeName, indexName, key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, "readonly");
const store = tx.objectStore(storeName);
const index = store.index(indexName);
const req = index.getAll(key);
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
async function dbDelete(storeName, key) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, "readwrite");
const req = tx.objectStore(storeName).delete(key);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
async function dbClear(storeName) {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, "readwrite");
const req = tx.objectStore(storeName).clear();
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
});
}
// Cascade delete: delete a profile and all related data
async function deleteProfile(profileId) {
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
for (const room of rooms) {
await deleteRoomData(room.id);
}
await deleteByIndex("ac_units", "profileId", profileId);
await deleteByIndex("forecasts", "profileId", profileId);
await deleteByIndex("warnings", "profileId", profileId);
// Delete toggles for this profile
const toggles = await dbGetAll("toggles");
for (const t of toggles) {
if (t.profileId === profileId) await dbDelete("toggles", t.id);
}
// Delete ac_assignments for ac_units that belonged to this profile
const allAssignments = await dbGetAll("ac_assignments");
const acUnits = await dbGetByIndex("ac_units", "profileId", profileId);
const acIds = new Set(acUnits.map(u => u.id));
for (const a of allAssignments) {
if (acIds.has(a.acId)) await dbDelete("ac_assignments", [a.acId, a.roomId]);
}
await dbDelete("profiles", profileId);
}
async function deleteRoomData(roomId) {
await deleteByIndex("devices", "roomId", roomId);
await deleteByIndex("occupants", "roomId", roomId);
await deleteByIndex("windows", "roomId", roomId);
// Delete ac_assignments for this room
const assignments = await dbGetAll("ac_assignments");
for (const a of assignments) {
if (a.roomId === roomId) await dbDelete("ac_assignments", [a.acId, a.roomId]);
}
await dbDelete("rooms", roomId);
}
async function deleteByIndex(storeName, indexName, key) {
const items = await dbGetByIndex(storeName, indexName, key);
for (const item of items) {
const pk = item.id !== undefined ? item.id : null;
if (pk !== null) await dbDelete(storeName, pk);
}
}
// Settings helpers
async function getSetting(key) {
const item = await dbGet("settings", key);
return item ? item.value : null;
}
async function setSetting(key, value) {
await dbPut("settings", { key, value });
}
async function getActiveProfileId() {
return await getSetting("activeProfileId");
}
async function setActiveProfileId(id) {
await setSetting("activeProfileId", id);
}
// Fetch forecast + warnings for a profile and store in IndexedDB.
// Returns { forecasts: number, warnings: number } counts.
async function fetchForecastForProfile(profileId) {
const profiles = await dbGetAll("profiles");
const profile = profiles.find(p => p.id === profileId);
if (!profile) throw new Error("Profile not found");
// 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(forecastBody),
});
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
await deleteByIndex("forecasts", "profileId", profileId);
const hourly = data.Hourly || data.hourly || [];
for (const h of hourly) {
await dbAdd("forecasts", {
profileId,
timestamp: h.Timestamp || h.timestamp,
temperatureC: h.TemperatureC ?? h.temperatureC ?? null,
humidityPct: h.HumidityPct ?? h.humidityPct ?? null,
cloudCoverPct: h.CloudCoverPct ?? h.cloudCoverPct ?? null,
sunshineMin: h.SunshineMin ?? h.sunshineMin ?? null,
apparentTempC: h.ApparentTempC ?? h.apparentTempC ?? null,
pressureHpa: h.PressureHpa ?? h.pressureHpa ?? null,
uvIndex: h.UVIndex ?? h.uvIndex ?? null,
});
}
// Warnings: use provider-supplied warnings if available, otherwise fall back to DWD
let warningCount = 0;
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++;
}
} 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 };
}
// Build full compute payload from IndexedDB
async function getComputePayload(profileId, dateStr) {
const profiles = await dbGetAll("profiles");
const profile = profiles.find(p => p.id === profileId);
if (!profile) return null;
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
const allDevices = [];
const allOccupants = [];
const roomWindows = {};
for (const room of rooms) {
const devices = await dbGetByIndex("devices", "roomId", room.id);
allDevices.push(...devices);
const occupants = await dbGetByIndex("occupants", "roomId", room.id);
allOccupants.push(...occupants);
const wins = await dbGetByIndex("windows", "roomId", room.id);
if (wins.length > 0) roomWindows[room.id] = wins;
}
const acUnits = await dbGetByIndex("ac_units", "profileId", profileId);
const allAssignments = await dbGetAll("ac_assignments");
const acIds = new Set(acUnits.map(u => u.id));
const acAssignments = allAssignments.filter(a => acIds.has(a.acId));
const forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
const warnings = await dbGetByIndex("warnings", "profileId", profileId);
// Toggles
const allToggles = await dbGetAll("toggles");
const toggles = {};
for (const t of allToggles) {
if (t.profileId === profileId && t.active) {
toggles[t.name] = true;
}
}
return {
profile: {
id: profile.id,
name: profile.name,
latitude: profile.latitude,
longitude: profile.longitude,
timezone: profile.timezone || "Europe/Berlin",
},
rooms: rooms.map(r => {
const rm = {
id: r.id,
profileId: r.profileId,
name: r.name,
areaSqm: r.areaSqm || 0,
ceilingHeightM: r.ceilingHeightM || 2.5,
floor: r.floor || 0,
orientation: r.orientation || "S",
shadingType: r.shadingType || "none",
shadingFactor: r.shadingFactor ?? 1.0,
ventilationAch: r.ventilationAch || 0.5,
windowFraction: r.windowFraction || 0.15,
shgc: r.shgc || 0.6,
insulation: r.insulation || "average",
indoorTempC: r.indoorTempC || 0,
indoorHumidityPct: r.indoorHumidityPct || null,
};
const wins = roomWindows[r.id];
if (wins && wins.length > 0) {
rm.windows = wins.map(w => ({
id: w.id,
roomId: w.roomId,
orientation: w.orientation || "S",
areaSqm: w.areaSqm || 0,
shgc: w.shgc || 0.6,
shadingType: w.shadingType || "none",
shadingFactor: w.shadingFactor ?? 1.0,
}));
}
return rm;
}),
devices: allDevices.map(d => ({
id: d.id,
roomId: d.roomId,
name: d.name,
deviceType: d.deviceType || "electronics",
wattsIdle: d.wattsIdle || 0,
wattsTypical: d.wattsTypical || 0,
wattsPeak: d.wattsPeak || 0,
dutyCycle: d.dutyCycle ?? 1.0,
})),
occupants: allOccupants.map(o => ({
id: o.id,
roomId: o.roomId,
count: o.count || 1,
activityLevel: o.activityLevel || "sedentary",
vulnerable: !!o.vulnerable,
})),
acUnits: acUnits.map(a => {
const unit = {
id: a.id,
profileId: a.profileId,
name: a.name,
acType: a.acType || "portable",
capacityBtu: a.capacityBtu || 0,
hasDehumidify: !!a.hasDehumidify,
efficiencyEer: a.efficiencyEer || 10,
canHeat: !!a.canHeat,
heatingCapacityBtu: a.heatingCapacityBtu || 0,
};
if (a.seer) unit.seer = a.seer;
if (a.seerClass) unit.seerClass = a.seerClass;
if (a.scop) unit.scop = a.scop;
if (a.scopClass) unit.scopClass = a.scopClass;
if (a.cop) unit.cop = a.cop;
if (a.tol !== undefined && a.tol !== 0) unit.tol = a.tol;
if (a.tbiv !== undefined && a.tbiv !== 0) unit.tbiv = a.tbiv;
if (a.refrigerant) unit.refrigerant = a.refrigerant;
return unit;
}),
acAssignments: acAssignments.map(a => ({
acId: a.acId,
roomId: a.roomId,
})),
toggles,
forecasts: forecasts.map(f => ({
timestamp: f.timestamp,
temperatureC: f.temperatureC ?? null,
humidityPct: f.humidityPct ?? null,
cloudCoverPct: f.cloudCoverPct ?? null,
sunshineMin: f.sunshineMin ?? null,
apparentTempC: f.apparentTempC ?? null,
pressureHpa: f.pressureHpa ?? null,
uvIndex: f.uvIndex ?? null,
})),
warnings: warnings.map(w => ({
headline: w.headline || "",
severity: w.severity || "",
description: w.description || "",
instruction: w.instruction || "",
onset: w.onset || "",
expires: w.expires || "",
})),
date: dateStr,
};
}