feat: rewrite to stateless web app with IndexedDB frontend

Replace CLI + SQLite architecture with a Go web server + vanilla JS
frontend using IndexedDB for all client-side data storage.

- Remove: cli, store, report, static packages
- Add: compute engine (BuildDashboard), server package, web UI
- Add: setup page with CRUD for profiles, rooms, devices, occupants, AC
- Add: dashboard with SVG temperature timeline, risk analysis, care checklist
- Add: i18n support (English/German) with server-side Go templates
- Add: LLM provider selection UI with client-side API key storage
- Add: per-room indoor temperature, edit buttons, language-aware AI summary
This commit is contained in:
2026-02-09 13:31:38 +01:00
parent a89720fded
commit d5452409b6
65 changed files with 3862 additions and 5332 deletions

354
web/js/dashboard.js Normal file
View File

@@ -0,0 +1,354 @@
// Dashboard page logic
(function() {
"use strict";
const $ = (id) => document.getElementById(id);
function show(id) { $(id).classList.remove("hidden"); }
function hide(id) { $(id).classList.add("hidden"); }
const riskColors = {
low: { bg: "bg-green-100 dark:bg-green-900", text: "text-green-700 dark:text-green-300", border: "border-green-500" },
moderate: { bg: "bg-yellow-100 dark:bg-yellow-900", text: "text-yellow-700 dark:text-yellow-300", border: "border-yellow-500" },
high: { bg: "bg-orange-100 dark:bg-orange-900", text: "text-orange-700 dark:text-orange-300", border: "border-orange-500" },
extreme: { bg: "bg-red-100 dark:bg-red-900", text: "text-red-700 dark:text-red-300", border: "border-red-500" },
};
const budgetColors = {
comfortable: "bg-green-500",
marginal: "bg-yellow-500",
overloaded: "bg-red-500",
};
const budgetHexColors = {
comfortable: "#22c55e",
marginal: "#eab308",
overloaded: "#ef4444",
};
function tempColorHex(temp) {
if (temp >= 40) return "#dc2626";
if (temp >= 35) return "#f97316";
if (temp >= 30) return "#facc15";
if (temp >= 25) return "#fde68a";
if (temp >= 20) return "#bbf7d0";
return "#bfdbfe";
}
window.loadDashboard = async function() {
hide("no-data");
hide("no-forecast");
hide("error-state");
hide("data-display");
show("loading");
try {
const profileId = await getActiveProfileId();
if (!profileId) {
hide("loading");
show("no-data");
return;
}
const forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
if (forecasts.length === 0) {
hide("loading");
show("no-forecast");
return;
}
const today = new Date().toISOString().slice(0, 10);
const payload = await getComputePayload(profileId, today);
if (!payload) {
hide("loading");
show("no-data");
return;
}
const resp = await fetch("/api/compute/dashboard", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.error || "Compute failed");
}
const data = await resp.json();
hide("loading");
show("data-display");
renderDashboard(data);
// LLM summary (async)
try {
const llmBody = {
date: data.date,
peakTempC: data.peakTempC,
minNightTempC: data.minNightTempC,
riskLevel: data.riskLevel,
acHeadroomBTUH: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].headroomBtuh : 0,
budgetStatus: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].status : "comfortable",
language: window.HG && window.HG.lang === "de" ? "German" : "English",
};
// Include client-side LLM settings if available
const llmProvider = await getSetting("llmProvider");
const llmApiKey = await getSetting("llmApiKey");
const llmModel = await getSetting("llmModel");
if (llmProvider && llmApiKey) {
llmBody.provider = llmProvider;
llmBody.apiKey = llmApiKey;
if (llmModel) llmBody.model = llmModel;
}
const llmResp = await fetch("/api/llm/summarize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(llmBody),
});
if (llmResp.ok) {
const llmData = await llmResp.json();
if (llmData.summary) {
$("llm-summary").innerHTML = `<p class="text-sm whitespace-pre-line">${esc(llmData.summary)}</p>
<p class="text-xs text-gray-400 mt-2">AI-generated summary. Not a substitute for professional advice.</p>`;
} else {
$("llm-section").classList.add("hidden");
}
}
} catch (e) {
$("llm-section").classList.add("hidden");
}
} catch (err) {
hide("loading");
show("error-state");
console.error("Dashboard error:", err);
}
};
function renderDashboard(data) {
$("profile-name").textContent = data.profileName + " \u2014 " + data.date;
// Risk card
const rc = riskColors[data.riskLevel] || riskColors.low;
$("risk-card").className = `rounded-xl p-4 text-center shadow-sm ${rc.bg}`;
$("risk-level").className = `text-2xl font-bold capitalize ${rc.text}`;
$("risk-level").textContent = data.riskLevel;
// Peak temp
$("peak-temp").textContent = data.peakTempC.toFixed(1) + "\u00b0C";
// Min night temp
$("min-night-temp").textContent = data.minNightTempC.toFixed(1) + "\u00b0C";
if (data.poorNightCool) {
$("poor-night-cool").classList.remove("hidden");
}
// Warnings
if (data.warnings && data.warnings.length > 0) {
show("warnings-section");
$("warnings-section").innerHTML = '<h2 class="text-lg font-semibold mb-2">Warnings</h2>' +
data.warnings.map(w => `
<div class="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg p-3">
<div class="font-medium text-red-700 dark:text-red-300">${esc(w.headline)}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">${esc(w.description)}</div>
${w.instruction ? `<div class="text-sm text-orange-700 dark:text-orange-300 mt-1">${esc(w.instruction)}</div>` : ''}
<div class="text-xs text-gray-400 mt-1">${esc(w.onset)} \u2014 ${esc(w.expires)}</div>
</div>
`).join("");
}
// Risk windows
if (data.riskWindows && data.riskWindows.length > 0) {
show("risk-windows-section");
$("risk-windows").innerHTML = data.riskWindows.map(w => {
const wc = riskColors[w.level] || riskColors.low;
return `
<div class="flex items-center gap-3 ${wc.bg} rounded-lg px-3 py-2">
<span class="font-mono text-sm">${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00</span>
<span class="capitalize font-medium ${wc.text}">${w.level}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${esc(w.reason)}</span>
</div>
`;
}).join("");
}
// Timeline SVG chart + budget strip
if (data.timeline && data.timeline.length > 0) {
renderTimelineChart(data.timeline);
renderBudgetStrip(data.timeline);
}
// Room budgets
if (data.roomBudgets && data.roomBudgets.length > 0) {
show("budgets-section");
$("room-budgets").innerHTML = data.roomBudgets.map(rb => {
const maxVal = Math.max(rb.totalGainBtuh, rb.acCapacityBtuh, 1);
const gainPct = Math.min((rb.totalGainBtuh / maxVal) * 100, 100);
const capPct = Math.min((rb.acCapacityBtuh / maxVal) * 100, 100);
return `
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm">
<div class="flex items-center justify-between mb-2">
<span class="font-medium">${esc(rb.roomName)}</span>
</div>
<div class="space-y-1 text-xs">
<div class="flex justify-between"><span>Internal</span><span>${rb.internalGainsW.toFixed(0)} W</span></div>
<div class="flex justify-between"><span>Solar</span><span>${rb.solarGainW.toFixed(0)} W</span></div>
<div class="flex justify-between"><span>Ventilation</span><span>${rb.ventGainW.toFixed(0)} W</span></div>
<div class="flex justify-between font-medium"><span>Total</span><span>${rb.totalGainBtuh.toFixed(0)} BTU/h</span></div>
<div class="flex justify-between"><span>AC Capacity</span><span>${rb.acCapacityBtuh.toFixed(0)} BTU/h</span></div>
<div class="flex justify-between font-medium"><span>Headroom</span><span>${rb.headroomBtuh.toFixed(0)} BTU/h</span></div>
</div>
<div class="mt-2 space-y-1">
<div class="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div class="h-full rounded-full bg-red-400" style="width: ${gainPct}%"></div>
</div>
<div class="h-2 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div class="h-full rounded-full bg-blue-400" style="width: ${capPct}%"></div>
</div>
<div class="flex justify-between text-xs text-gray-400">
<span>Gain</span><span>AC</span>
</div>
</div>
</div>
`;
}).join("");
}
// Care checklist
if (data.careChecklist && data.careChecklist.length > 0) {
show("care-section");
$("care-checklist").innerHTML = data.careChecklist.map(item => `
<li class="flex items-start gap-2">
<input type="checkbox" class="mt-1 rounded">
<span class="text-sm">${esc(item)}</span>
</li>
`).join("");
}
}
// ========== SVG Timeline Chart ==========
function renderTimelineChart(timeline) {
const container = $("timeline-chart");
const width = 720;
const height = 200;
const padLeft = 40;
const padRight = 10;
const padTop = 15;
const padBottom = 25;
const chartW = width - padLeft - padRight;
const chartH = height - padTop - padBottom;
const temps = timeline.map(s => s.tempC);
const minTemp = Math.floor(Math.min(...temps) / 5) * 5;
const maxTemp = Math.ceil(Math.max(...temps) / 5) * 5;
const tempRange = maxTemp - minTemp || 10;
function x(i) { return padLeft + (i / (timeline.length - 1)) * chartW; }
function y(t) { return padTop + (1 - (t - minTemp) / tempRange) * chartH; }
// Build area path (temperature curve filled to bottom)
const points = timeline.map((s, i) => `${x(i).toFixed(1)},${y(s.tempC).toFixed(1)}`);
const linePath = `M${points.join(" L")}`;
const areaPath = `${linePath} L${x(timeline.length - 1).toFixed(1)},${(padTop + chartH).toFixed(1)} L${padLeft},${(padTop + chartH).toFixed(1)} Z`;
// Build gradient stops based on temperature
const gradientStops = timeline.map((s, i) => {
const pct = ((i / (timeline.length - 1)) * 100).toFixed(1);
return `<stop offset="${pct}%" stop-color="${tempColorHex(s.tempC)}" stop-opacity="0.4"/>`;
}).join("");
const gradientStopsLine = timeline.map((s, i) => {
const pct = ((i / (timeline.length - 1)) * 100).toFixed(1);
return `<stop offset="${pct}%" stop-color="${tempColorHex(s.tempC)}"/>`;
}).join("");
// Threshold lines
const thresholds = [30, 35, 40].filter(t => t > minTemp && t < maxTemp);
const thresholdLines = thresholds.map(t =>
`<line x1="${padLeft}" y1="${y(t).toFixed(1)}" x2="${padLeft + chartW}" y2="${y(t).toFixed(1)}" stroke="#9ca3af" stroke-width="0.5" stroke-dasharray="4,3"/>
<text x="${padLeft - 3}" y="${(y(t) + 3).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${t}\u00b0</text>`
).join("");
// Y-axis labels (min, max)
const yLabels = `
<text x="${padLeft - 3}" y="${(padTop + 4).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${maxTemp}\u00b0</text>
<text x="${padLeft - 3}" y="${(padTop + chartH + 3).toFixed(1)}" text-anchor="end" fill="#9ca3af" font-size="9">${minTemp}\u00b0</text>
`;
// X-axis labels (every 3 hours)
const xLabels = timeline
.filter((s, i) => s.hour % 3 === 0)
.map((s, i) => {
const idx = timeline.findIndex(t => t.hour === s.hour);
return `<text x="${x(idx).toFixed(1)}" y="${(height - 3).toFixed(1)}" text-anchor="middle" fill="#9ca3af" font-size="9">${String(s.hour).padStart(2, '0')}</text>`;
}).join("");
// Data points (circles)
const circles = timeline.map((s, i) =>
`<circle cx="${x(i).toFixed(1)}" cy="${y(s.tempC).toFixed(1)}" r="4" fill="${tempColorHex(s.tempC)}" stroke="white" stroke-width="1.5" class="timeline-dot" data-idx="${i}" style="cursor:pointer"/>`
).join("");
const svg = `<svg viewBox="0 0 ${width} ${height}" class="w-full" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="1" y2="0">${gradientStops}</linearGradient>
<linearGradient id="lineGrad" x1="0" y1="0" x2="1" y2="0">${gradientStopsLine}</linearGradient>
</defs>
<path d="${areaPath}" fill="url(#areaGrad)"/>
<path d="${linePath}" fill="none" stroke="url(#lineGrad)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
${thresholdLines}
${yLabels}
${xLabels}
${circles}
</svg>`;
container.innerHTML = svg;
// Tooltip on hover/click
const tooltip = $("timeline-tooltip");
container.querySelectorAll(".timeline-dot").forEach(dot => {
const handler = (e) => {
const idx = parseInt(dot.dataset.idx);
const slot = timeline[idx];
const actions = slot.actions && slot.actions.length > 0
? slot.actions.map(a => `\u2022 ${esc(a.name)}`).join("<br>")
: "No actions";
tooltip.innerHTML = `
<div class="font-medium mb-1">${slot.hourStr}</div>
<div>${slot.tempC.toFixed(1)}\u00b0C · ${(slot.humidityPct || 0).toFixed(0)}% RH</div>
<div class="capitalize">${slot.budgetStatus}</div>
<div class="mt-1 text-gray-300">${actions}</div>
`;
const rect = dot.getBoundingClientRect();
tooltip.style.left = (rect.left + window.scrollX - 60) + "px";
tooltip.style.top = (rect.top + window.scrollY - tooltip.offsetHeight - 8) + "px";
tooltip.classList.remove("hidden");
};
dot.addEventListener("mouseenter", handler);
dot.addEventListener("click", handler);
});
container.addEventListener("mouseleave", () => tooltip.classList.add("hidden"));
}
// ========== Budget Status Strip ==========
function renderBudgetStrip(timeline) {
const strip = $("timeline-strip");
strip.innerHTML = timeline.map(slot => {
const color = budgetHexColors[slot.budgetStatus] || "#d1d5db";
return `<div class="flex-1 h-3 rounded-sm" style="background:${color}" title="${slot.hourStr}: ${slot.budgetStatus}"></div>`;
}).join("");
}
function esc(s) {
if (!s) return "";
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
// Init
loadDashboard();
})();

290
web/js/db.js Normal file
View File

@@ -0,0 +1,290 @@
// 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);
}
// 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,
})),
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,
})),
warnings: warnings.map(w => ({
headline: w.headline || "",
severity: w.severity || "",
description: w.description || "",
instruction: w.instruction || "",
onset: w.onset || "",
expires: w.expires || "",
})),
date: dateStr,
};
}

685
web/js/setup.js Normal file
View File

@@ -0,0 +1,685 @@
// Setup page logic
(function() {
"use strict";
// Tab switching
const tabBtns = document.querySelectorAll(".tab-btn");
const tabPanels = document.querySelectorAll(".tab-panel");
tabBtns.forEach(btn => {
btn.addEventListener("click", () => {
const tab = btn.dataset.tab;
tabBtns.forEach(b => {
b.classList.remove("border-orange-600", "text-orange-600", "dark:text-orange-400", "dark:border-orange-400");
b.classList.add("border-transparent", "text-gray-500");
});
btn.classList.add("border-orange-600", "text-orange-600", "dark:text-orange-400", "dark:border-orange-400");
btn.classList.remove("border-transparent", "text-gray-500");
tabPanels.forEach(p => p.classList.add("hidden"));
document.getElementById("tab-" + tab).classList.remove("hidden");
});
});
// Hash-based tab navigation
if (location.hash) {
const tab = location.hash.slice(1);
const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`);
if (btn) btn.click();
}
// Toast
function showToast(msg, isError) {
const toast = document.getElementById("toast");
toast.textContent = msg;
toast.className = "fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg text-sm z-50 transition-opacity";
toast.classList.add(...(isError ? ["bg-red-600", "text-white"] : ["bg-green-600", "text-white"]));
toast.classList.remove("hidden");
setTimeout(() => toast.classList.add("hidden"), 3000);
}
// Tooltip handling
document.addEventListener("click", (e) => {
const trigger = e.target.closest(".tooltip-trigger");
if (!trigger) {
document.querySelectorAll(".tooltip-popup").forEach(p => p.remove());
return;
}
document.querySelectorAll(".tooltip-popup").forEach(p => p.remove());
const tip = document.createElement("div");
tip.className = "tooltip-popup absolute z-50 p-2 bg-gray-800 text-white text-xs rounded-lg shadow-lg max-w-xs";
tip.textContent = trigger.dataset.tooltip;
trigger.parentElement.style.position = "relative";
trigger.parentElement.appendChild(tip);
setTimeout(() => tip.remove(), 5000);
});
// Form helpers
function formData(form) {
const data = {};
const fd = new FormData(form);
for (const [key, val] of fd.entries()) {
data[key] = val;
}
return data;
}
function resetForm(form) {
form.reset();
const hidden = form.querySelector('input[name="id"]');
if (hidden) hidden.value = "";
}
function numOrDefault(val, def) {
const n = parseFloat(val);
return isNaN(n) ? def : n;
}
// ========== Profiles ==========
async function loadProfiles() {
const profiles = await dbGetAll("profiles");
const list = document.getElementById("profiles-list");
if (profiles.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500">No profiles yet.</p>';
return;
}
const activeId = await getActiveProfileId();
list.innerHTML = profiles.map(p => {
const isActive = activeId === p.id;
return `
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
<div>
<span class="font-medium">${esc(p.name)}</span>
<span class="text-xs text-gray-400 ml-2">${p.latitude.toFixed(4)}, ${p.longitude.toFixed(4)} · ${esc(p.timezone || "")}</span>
</div>
<div class="flex gap-2">
<button onclick="activateProfile(${p.id})" class="text-xs px-2 py-1 rounded ${isActive ? 'bg-orange-600 text-white' : 'bg-gray-100 dark:bg-gray-700'}">
${isActive ? '● Active' : 'Set Active'}
</button>
<button onclick="editProfileUI(${p.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
<button onclick="deleteProfileUI(${p.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
</div>
</div>`;
}).join("");
}
window.activateProfile = async function(id) {
await setActiveProfileId(id);
await loadProfiles();
await refreshRoomSelects();
showToast("Profile activated", false);
};
window.editProfileUI = async function(id) {
const p = await dbGet("profiles", id);
if (!p) return;
const form = document.getElementById("profile-form");
form.querySelector('input[name="id"]').value = p.id;
form.querySelector('input[name="name"]').value = p.name;
form.querySelector('input[name="latitude"]').value = p.latitude;
form.querySelector('input[name="longitude"]').value = p.longitude;
form.querySelector('input[name="timezone"]').value = p.timezone || "Europe/Berlin";
form.querySelector('input[name="name"]').focus();
};
window.deleteProfileUI = async function(id) {
if (!confirm("Delete this profile and all its data?")) return;
const activeId = await getActiveProfileId();
await deleteProfile(id);
if (activeId === id) await setSetting("activeProfileId", null);
await loadProfiles();
showToast("Profile deleted", false);
};
document.getElementById("profile-form").addEventListener("submit", async (e) => {
e.preventDefault();
const data = formData(e.target);
const profile = {
name: data.name,
latitude: numOrDefault(data.latitude, 0),
longitude: numOrDefault(data.longitude, 0),
timezone: data.timezone || "Europe/Berlin",
};
if (data.id) {
profile.id = parseInt(data.id);
await dbPut("profiles", profile);
} else {
const id = await dbAdd("profiles", profile);
// Auto-activate if first profile
const profiles = await dbGetAll("profiles");
if (profiles.length === 1) await setActiveProfileId(id);
}
resetForm(e.target);
await loadProfiles();
showToast("Profile saved", false);
});
// Geolocation
document.getElementById("geolocate-btn").addEventListener("click", () => {
const btn = document.getElementById("geolocate-btn");
btn.disabled = true;
btn.textContent = "⟳ Detecting…";
navigator.geolocation.getCurrentPosition(
(pos) => {
document.querySelector('#profile-form input[name="latitude"]').value = pos.coords.latitude.toFixed(4);
document.querySelector('#profile-form input[name="longitude"]').value = pos.coords.longitude.toFixed(4);
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (tz) document.querySelector('#profile-form input[name="timezone"]').value = tz;
btn.disabled = false;
btn.textContent = "📍 Use my location";
},
(err) => {
const msgs = { 1: "Permission denied.", 2: "Location unavailable.", 3: "Timed out." };
showToast(msgs[err.code] || "Location error.", true);
btn.disabled = false;
btn.textContent = "📍 Use my location";
},
{ timeout: 10000 }
);
});
// ========== Rooms ==========
async function loadRooms() {
const profileId = await getActiveProfileId();
if (!profileId) return;
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
const list = document.getElementById("rooms-list");
if (rooms.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-400">No rooms yet.</p>';
return;
}
list.innerHTML = rooms.map(r => `
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
<div>
<span class="font-medium">${esc(r.name)}</span>
<span class="text-xs text-gray-400 ml-2">${r.areaSqm}m² · ${r.orientation} · SHGC ${r.shgc} · ${r.indoorTempC || 25}°C</span>
</div>
<button onclick="editRoomUI(${r.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
<button onclick="deleteRoomUI(${r.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
</div>
`).join("");
}
window.editRoomUI = async function(id) {
const r = await dbGet("rooms", id);
if (!r) return;
const form = document.getElementById("room-form");
form.querySelector('input[name="id"]').value = r.id;
form.querySelector('input[name="name"]').value = r.name;
form.querySelector('input[name="areaSqm"]').value = r.areaSqm || "";
form.querySelector('input[name="ceilingHeightM"]').value = r.ceilingHeightM || 2.5;
form.querySelector('input[name="floor"]').value = r.floor || 0;
form.querySelector('select[name="orientation"]').value = r.orientation || "S";
form.querySelector('select[name="shadingType"]').value = r.shadingType || "none";
form.querySelector('input[name="shadingFactor"]').value = r.shadingFactor ?? 1.0;
form.querySelector('input[name="ventilationAch"]').value = r.ventilationAch || 0.5;
form.querySelector('input[name="windowFraction"]').value = r.windowFraction || 0.15;
form.querySelector('input[name="shgc"]').value = r.shgc || 0.6;
form.querySelector('select[name="insulation"]').value = r.insulation || "average";
form.querySelector('input[name="indoorTempC"]').value = r.indoorTempC || 25;
form.querySelector('input[name="name"]').focus();
};
window.deleteRoomUI = async function(id) {
if (!confirm("Delete this room and its devices/occupants?")) return;
await deleteRoomData(id);
await loadRooms();
await refreshRoomSelects();
showToast("Room deleted", false);
};
document.getElementById("room-form").addEventListener("submit", async (e) => {
e.preventDefault();
const profileId = await getActiveProfileId();
if (!profileId) { showToast("Select a profile first", true); return; }
const data = formData(e.target);
const room = {
profileId,
name: data.name,
areaSqm: numOrDefault(data.areaSqm, 15),
ceilingHeightM: numOrDefault(data.ceilingHeightM, 2.5),
floor: parseInt(data.floor) || 0,
orientation: data.orientation || "S",
shadingType: data.shadingType || "none",
shadingFactor: numOrDefault(data.shadingFactor, 1.0),
ventilation: data.ventilation || "natural",
ventilationAch: numOrDefault(data.ventilationAch, 0.5),
windowFraction: numOrDefault(data.windowFraction, 0.15),
shgc: numOrDefault(data.shgc, 0.6),
insulation: data.insulation || "average",
indoorTempC: numOrDefault(data.indoorTempC, 25),
};
if (data.id) {
room.id = parseInt(data.id);
await dbPut("rooms", room);
} else {
await dbAdd("rooms", room);
}
resetForm(e.target);
await loadRooms();
await refreshRoomSelects();
showToast("Room saved", false);
});
// ========== Devices ==========
async function loadDevices() {
const profileId = await getActiveProfileId();
if (!profileId) return;
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
const allDevices = [];
for (const room of rooms) {
const devices = await dbGetByIndex("devices", "roomId", room.id);
for (const d of devices) { d._roomName = room.name; allDevices.push(d); }
}
const list = document.getElementById("devices-list");
if (allDevices.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-400">No devices yet.</p>';
return;
}
list.innerHTML = allDevices.map(d => `
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
<div>
<span class="font-medium">${esc(d.name)}</span>
<span class="text-xs text-gray-400 ml-2">${esc(d._roomName)} · ${d.wattsTypical}W typical</span>
</div>
<button onclick="editDeviceUI(${d.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
<button onclick="deleteDeviceUI(${d.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
</div>
`).join("");
}
window.editDeviceUI = async function(id) {
const d = await dbGet("devices", id);
if (!d) return;
const form = document.getElementById("device-form");
form.querySelector('input[name="id"]').value = d.id;
form.querySelector('select[name="roomId"]').value = d.roomId;
form.querySelector('input[name="name"]').value = d.name;
form.querySelector('input[name="deviceType"]').value = d.deviceType || "electronics";
form.querySelector('input[name="wattsIdle"]').value = d.wattsIdle || 0;
form.querySelector('input[name="wattsTypical"]').value = d.wattsTypical || 0;
form.querySelector('input[name="wattsPeak"]').value = d.wattsPeak || 0;
form.querySelector('input[name="dutyCycle"]').value = d.dutyCycle ?? 1.0;
form.querySelector('input[name="name"]').focus();
};
window.deleteDeviceUI = async function(id) {
await dbDelete("devices", id);
await loadDevices();
showToast("Device deleted", false);
};
document.getElementById("device-form").addEventListener("submit", async (e) => {
e.preventDefault();
const data = formData(e.target);
const device = {
roomId: parseInt(data.roomId),
name: data.name,
deviceType: data.deviceType || "electronics",
wattsIdle: numOrDefault(data.wattsIdle, 0),
wattsTypical: numOrDefault(data.wattsTypical, 0),
wattsPeak: numOrDefault(data.wattsPeak, 0),
dutyCycle: numOrDefault(data.dutyCycle, 1.0),
};
if (data.id) {
device.id = parseInt(data.id);
await dbPut("devices", device);
} else {
await dbAdd("devices", device);
}
resetForm(e.target);
await loadDevices();
showToast("Device saved", false);
});
// ========== Occupants ==========
async function loadOccupants() {
const profileId = await getActiveProfileId();
if (!profileId) return;
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
const allOccupants = [];
for (const room of rooms) {
const occupants = await dbGetByIndex("occupants", "roomId", room.id);
for (const o of occupants) { o._roomName = room.name; allOccupants.push(o); }
}
const list = document.getElementById("occupants-list");
if (allOccupants.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-400">No occupants yet.</p>';
return;
}
list.innerHTML = allOccupants.map(o => `
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
<div>
<span class="font-medium">${o.count}x ${esc(o.activityLevel)}</span>
<span class="text-xs text-gray-400 ml-2">${esc(o._roomName)}${o.vulnerable ? ' · ⚠ vulnerable' : ''}</span>
</div>
<button onclick="editOccupantUI(${o.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
<button onclick="deleteOccupantUI(${o.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
</div>
`).join("");
}
window.editOccupantUI = async function(id) {
const o = await dbGet("occupants", id);
if (!o) return;
const form = document.getElementById("occupant-form");
form.querySelector('input[name="id"]').value = o.id;
form.querySelector('select[name="roomId"]').value = o.roomId;
form.querySelector('input[name="count"]').value = o.count || 1;
form.querySelector('select[name="activityLevel"]').value = o.activityLevel || "sedentary";
form.querySelector('input[name="vulnerable"]').checked = !!o.vulnerable;
form.querySelector('input[name="count"]').focus();
};
window.deleteOccupantUI = async function(id) {
await dbDelete("occupants", id);
await loadOccupants();
showToast("Occupant deleted", false);
};
document.getElementById("occupant-form").addEventListener("submit", async (e) => {
e.preventDefault();
const data = formData(e.target);
const occupant = {
roomId: parseInt(data.roomId),
count: parseInt(data.count) || 1,
activityLevel: data.activityLevel || "sedentary",
vulnerable: !!data.vulnerable,
};
if (data.id) {
occupant.id = parseInt(data.id);
await dbPut("occupants", occupant);
} else {
await dbAdd("occupants", occupant);
}
resetForm(e.target);
await loadOccupants();
showToast("Occupant saved", false);
});
// ========== AC Units ==========
async function loadACUnits() {
const profileId = await getActiveProfileId();
if (!profileId) return;
const units = await dbGetByIndex("ac_units", "profileId", profileId);
const list = document.getElementById("ac-list");
if (units.length === 0) {
list.innerHTML = '<p class="text-sm text-gray-400">No AC units yet.</p>';
return;
}
const assignments = await dbGetAll("ac_assignments");
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
const roomMap = Object.fromEntries(rooms.map(r => [r.id, r.name]));
list.innerHTML = units.map(u => {
const roomIds = assignments.filter(a => a.acId === u.id).map(a => a.roomId);
const roomNames = roomIds.map(id => roomMap[id] || `Room ${id}`).join(", ");
return `
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between">
<div>
<span class="font-medium">${esc(u.name)}</span>
<span class="text-xs text-gray-400 ml-2">${u.capacityBtu} BTU · ${esc(u.acType)}${roomNames ? ' · ' + esc(roomNames) : ''}</span>
</div>
<button onclick="editACUI(${u.id})" class="text-xs text-blue-500 hover:text-blue-700">✎</button>
<button onclick="deleteACUI(${u.id})" class="text-xs text-red-500 hover:text-red-700">✕</button>
</div>
`;
}).join("");
}
window.editACUI = async function(id) {
const u = await dbGet("ac_units", id);
if (!u) return;
const form = document.getElementById("ac-form");
form.querySelector('input[name="id"]').value = u.id;
form.querySelector('input[name="name"]').value = u.name;
form.querySelector('select[name="acType"]').value = u.acType || "portable";
form.querySelector('input[name="capacityBtu"]').value = u.capacityBtu || 0;
form.querySelector('input[name="efficiencyEer"]').value = u.efficiencyEer || 10;
form.querySelector('input[name="hasDehumidify"]').checked = !!u.hasDehumidify;
// Check assigned rooms
const assignments = await dbGetAll("ac_assignments");
const assignedRoomIds = new Set(assignments.filter(a => a.acId === id).map(a => a.roomId));
document.querySelectorAll('#ac-room-checkboxes input').forEach(cb => {
cb.checked = assignedRoomIds.has(parseInt(cb.value));
});
form.querySelector('input[name="name"]').focus();
};
window.deleteACUI = async function(id) {
await dbDelete("ac_units", id);
const assignments = await dbGetAll("ac_assignments");
for (const a of assignments) {
if (a.acId === id) await dbDelete("ac_assignments", [a.acId, a.roomId]);
}
await loadACUnits();
showToast("AC unit deleted", false);
};
document.getElementById("ac-form").addEventListener("submit", async (e) => {
e.preventDefault();
const profileId = await getActiveProfileId();
if (!profileId) { showToast("Select a profile first", true); return; }
const data = formData(e.target);
const unit = {
profileId,
name: data.name,
acType: data.acType || "portable",
capacityBtu: numOrDefault(data.capacityBtu, 0),
efficiencyEer: numOrDefault(data.efficiencyEer, 10),
hasDehumidify: !!data.hasDehumidify,
};
let acId;
if (data.id) {
unit.id = parseInt(data.id);
await dbPut("ac_units", unit);
acId = unit.id;
} else {
acId = await dbAdd("ac_units", unit);
}
// Save room assignments
const oldAssignments = await dbGetAll("ac_assignments");
for (const a of oldAssignments) {
if (a.acId === acId) await dbDelete("ac_assignments", [a.acId, a.roomId]);
}
const checkboxes = document.querySelectorAll('#ac-room-checkboxes input:checked');
for (const cb of checkboxes) {
await dbPut("ac_assignments", { acId, roomId: parseInt(cb.value) });
}
resetForm(e.target);
await loadACUnits();
showToast("AC unit saved", false);
});
// ========== Toggles ==========
async function loadToggles() {
const profileId = await getActiveProfileId();
if (!profileId) return;
const toggles = await dbGetAll("toggles");
document.querySelectorAll(".toggle-switch").forEach(el => {
const name = el.dataset.toggle;
const t = toggles.find(t => t.profileId === profileId && t.name === name);
el.checked = t ? t.active : false;
});
}
document.querySelectorAll(".toggle-switch").forEach(el => {
el.addEventListener("change", async () => {
const profileId = await getActiveProfileId();
if (!profileId) return;
const name = el.dataset.toggle;
const toggles = await dbGetAll("toggles");
const existing = toggles.find(t => t.profileId === profileId && t.name === name);
if (existing) {
existing.active = el.checked;
await dbPut("toggles", existing);
} else {
await dbAdd("toggles", { profileId, name, active: el.checked });
}
});
});
// ========== Forecast ==========
document.getElementById("fetch-forecast-btn").addEventListener("click", async () => {
const profileId = await getActiveProfileId();
if (!profileId) { showToast("Select a profile first", true); return; }
const profiles = await dbGetAll("profiles");
const profile = profiles.find(p => p.id === profileId);
if (!profile) return;
const btn = document.getElementById("fetch-forecast-btn");
const spinner = document.getElementById("forecast-spinner");
btn.disabled = true;
spinner.classList.remove("hidden");
try {
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 for this profile
await deleteByIndex("forecasts", "profileId", profileId);
// Store hourly forecasts
for (const h of (data.Hourly || data.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,
});
}
// Also fetch warnings
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 || "",
});
}
}
} catch (e) { /* warnings are optional */ }
await setSetting("lastFetched", new Date().toISOString());
document.getElementById("last-fetched").textContent = new Date().toLocaleString();
showToast("Forecast fetched", false);
} catch (err) {
showToast("Fetch failed: " + err.message, true);
} finally {
btn.disabled = false;
spinner.classList.add("hidden");
}
});
async function loadForecastStatus() {
const lastFetched = await getSetting("lastFetched");
if (lastFetched) {
document.getElementById("last-fetched").textContent = new Date(lastFetched).toLocaleString();
}
}
// ========== LLM Config ==========
async function loadLLMConfig() {
// Show server-side info
try {
const resp = await fetch("/api/llm/config");
const data = await resp.json();
const infoEl = document.getElementById("llm-server-info");
if (data.available) {
infoEl.textContent = `Server: ${data.provider} (${data.model || "default"})`;
infoEl.classList.remove("hidden");
}
} catch (e) { /* ignore */ }
// Load saved client-side LLM settings
const provider = await getSetting("llmProvider");
const apiKey = await getSetting("llmApiKey");
const model = await getSetting("llmModel");
if (provider) document.getElementById("llm-provider-select").value = provider;
if (apiKey) document.getElementById("llm-api-key").value = apiKey;
if (model) document.getElementById("llm-model").value = model;
}
document.getElementById("llm-form").addEventListener("submit", async (e) => {
e.preventDefault();
const provider = document.getElementById("llm-provider-select").value;
const apiKey = document.getElementById("llm-api-key").value;
const model = document.getElementById("llm-model").value;
await setSetting("llmProvider", provider);
await setSetting("llmApiKey", apiKey);
await setSetting("llmModel", model);
showToast("LLM settings saved", false);
});
// ========== Room selects for device/occupant/AC forms ==========
async function refreshRoomSelects() {
const profileId = await getActiveProfileId();
if (!profileId) return;
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
const options = rooms.map(r => `<option value="${r.id}">${esc(r.name)}</option>`).join("");
["device-room-select", "occupant-room-select"].forEach(id => {
const el = document.getElementById(id);
if (el) el.innerHTML = options;
});
// AC room checkboxes
const acCheckboxes = document.getElementById("ac-room-checkboxes");
if (acCheckboxes) {
acCheckboxes.innerHTML = rooms.map(r => `
<label class="flex items-center gap-1 text-sm">
<input type="checkbox" value="${r.id}" class="rounded"> ${esc(r.name)}
</label>
`).join("");
}
}
// ========== Utility ==========
function esc(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
// ========== Init ==========
async function init() {
await loadProfiles();
await loadRooms();
await loadDevices();
await loadOccupants();
await loadACUnits();
await loadToggles();
await loadForecastStatus();
await loadLLMConfig();
await refreshRoomSelects();
}
init();
})();