// IndexedDB wrapper for HeatGuard const DB_NAME = "heatguard"; const DB_VERSION = 1; 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" }, }; 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; 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 }); } } } } }; 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); // 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"); // Fetch forecast const resp = await fetch("/api/weather/forecast", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lat: profile.latitude, lon: profile.longitude, timezone: profile.timezone || "Europe/Berlin", }), }); if (!resp.ok) throw new Error(await resp.text()); 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, }); } // Fetch warnings (optional — don't fail if this errors) 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++; } } } 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 = []; 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 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 => ({ 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, ventilation: r.ventilation || "natural", 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, })), 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 => ({ id: a.id, profileId: a.profileId, name: a.name, acType: a.acType || "portable", capacityBtu: a.capacityBtu || 0, hasDehumidify: !!a.hasDehumidify, efficiencyEer: a.efficiencyEer || 10, })), 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, })), warnings: warnings.map(w => ({ headline: w.headline || "", severity: w.severity || "", description: w.description || "", instruction: w.instruction || "", onset: w.onset || "", expires: w.expires || "", })), date: dateStr, }; }