Files
HeatGuard/web/js/db.js
vikingowl 84d645ff21 feat: fix cold-weather thermal logic, add comfort mode, and dashboard forecast refresh
- Fix ComputeRoomBudget: no-AC rooms check if open-window ventilation
  can offset gains instead of defaulting to Overloaded. Net-cooling
  rooms are now Comfortable; ventilation-solvable rooms are Marginal.
- Add "comfort" cool mode for hours where outdoor is >5°C below indoor
  and budget is not overloaded (winter/cold scenarios).
- Reorder determineCoolMode: sealed now before overloaded, fixing
  humid+cold+no-AC giving "overloaded" instead of "sealed".
- Update LLM prompts: document comfort coolMode, add cold-weather
  guidance for summary and actions generation.
- Add dashboard forecast refresh button: fetches fresh forecast +
  warnings, then re-runs compute and LLM pipelines.
- Extract forecast fetch into shared fetchForecastForProfile() in db.js,
  deduplicating logic between setup.js and dashboard.js.
- Add indoor humidity support, pressure display, and cool mode sealed
  integration test.
2026-02-10 04:26:53 +01:00

359 lines
12 KiB
JavaScript

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