// 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, }; }