1196 lines
46 KiB
JavaScript
1196 lines
46 KiB
JavaScript
// 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;
|
|
// Reset edit mode on all forms when switching tabs
|
|
document.querySelectorAll(".tab-panel form").forEach(f => resetForm(f));
|
|
// Hide windows section when leaving rooms tab
|
|
const winSection = document.getElementById("windows-section");
|
|
if (winSection) winSection.classList.add("hidden");
|
|
|
|
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();
|
|
}
|
|
|
|
// Template cloning helper
|
|
function cloneTemplate(id) {
|
|
return document.getElementById(id).content.cloneNode(true);
|
|
}
|
|
|
|
// Toast
|
|
let _toastTimer = null;
|
|
function showToast(msg, isError) {
|
|
const toast = document.getElementById("toast");
|
|
if (_toastTimer) clearTimeout(_toastTimer);
|
|
toast.className = "fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg text-sm z-50 transition-opacity flex items-center gap-2";
|
|
toast.classList.add(...(isError ? ["bg-red-600", "text-white"] : ["bg-green-600", "text-white"]));
|
|
const tpl = cloneTemplate("tpl-toast");
|
|
tpl.querySelector('[data-slot="message"]').textContent = msg;
|
|
tpl.querySelector('[data-action="close"]').addEventListener("click", () => toast.classList.add("hidden"));
|
|
toast.replaceChildren(tpl);
|
|
toast.classList.remove("hidden");
|
|
_toastTimer = setTimeout(() => toast.classList.add("hidden"), isError ? 8000 : 3000);
|
|
}
|
|
|
|
// Tooltip handling (hover-based)
|
|
document.addEventListener("mouseenter", (e) => {
|
|
const trigger = e.target.closest(".tooltip-trigger");
|
|
if (!trigger) 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);
|
|
}, true);
|
|
|
|
document.addEventListener("mouseleave", (e) => {
|
|
const trigger = e.target.closest(".tooltip-trigger");
|
|
if (!trigger) return;
|
|
document.querySelectorAll(".tooltip-popup").forEach(p => p.remove());
|
|
}, true);
|
|
|
|
// 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 = "";
|
|
exitEditMode(form);
|
|
}
|
|
|
|
function numOrDefault(val, def) {
|
|
const n = parseFloat(val);
|
|
return isNaN(n) ? def : n;
|
|
}
|
|
|
|
// Edit mode helpers
|
|
function enterEditMode(form) {
|
|
form.classList.add("ring-2", "ring-orange-400");
|
|
const submitBtn = form.querySelector(".submit-btn");
|
|
const cancelBtn = form.querySelector(".cancel-btn");
|
|
if (submitBtn) submitBtn.textContent = submitBtn.dataset.saveText;
|
|
if (cancelBtn) cancelBtn.classList.remove("hidden");
|
|
}
|
|
|
|
function exitEditMode(form) {
|
|
form.classList.remove("ring-2", "ring-orange-400");
|
|
const submitBtn = form.querySelector(".submit-btn");
|
|
const cancelBtn = form.querySelector(".cancel-btn");
|
|
if (submitBtn) submitBtn.textContent = submitBtn.dataset.addText;
|
|
if (cancelBtn) cancelBtn.classList.add("hidden");
|
|
}
|
|
|
|
// Wire up all cancel buttons
|
|
document.querySelectorAll(".cancel-btn").forEach(btn => {
|
|
btn.addEventListener("click", () => {
|
|
const form = btn.closest("form");
|
|
if (form) resetForm(form);
|
|
});
|
|
});
|
|
|
|
// ========== 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.replaceChildren();
|
|
for (const p of profiles) {
|
|
const el = cloneTemplate("tpl-profile-card");
|
|
const isActive = activeId === p.id;
|
|
el.querySelector('[data-slot="name"]').textContent = p.name;
|
|
el.querySelector('[data-slot="details"]').textContent = `${p.latitude.toFixed(4)}, ${p.longitude.toFixed(4)} \u00b7 ${p.timezone || ""}`;
|
|
const actBtn = el.querySelector('[data-action="activate"]');
|
|
actBtn.textContent = isActive ? "\u25cf Active" : "Set Active";
|
|
if (isActive) {
|
|
actBtn.classList.add("bg-orange-600", "text-white");
|
|
} else {
|
|
actBtn.classList.add("bg-gray-100", "dark:bg-gray-700");
|
|
}
|
|
el.firstElementChild.dataset.id = p.id;
|
|
list.appendChild(el);
|
|
}
|
|
}
|
|
|
|
async function activateProfile(id) {
|
|
await setActiveProfileId(id);
|
|
await loadProfiles();
|
|
await refreshRoomSelects();
|
|
showToast("Profile activated", false);
|
|
}
|
|
|
|
async function editProfileUI(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";
|
|
enterEditMode(form);
|
|
form.querySelector('input[name="name"]').focus();
|
|
}
|
|
|
|
async function deleteProfileUI(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();
|
|
await updateTabBadges();
|
|
showToast("Profile deleted", false);
|
|
}
|
|
|
|
// Event delegation for profiles list
|
|
document.getElementById("profiles-list").addEventListener("click", async (e) => {
|
|
const btn = e.target.closest("[data-action]");
|
|
if (!btn) return;
|
|
const card = btn.closest("[data-id]");
|
|
if (!card) return;
|
|
const id = parseInt(card.dataset.id);
|
|
if (btn.dataset.action === "edit") await editProfileUI(id);
|
|
else if (btn.dataset.action === "delete") await deleteProfileUI(id);
|
|
else if (btn.dataset.action === "activate") await activateProfile(id);
|
|
});
|
|
|
|
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();
|
|
await updateTabBadges();
|
|
showToast("Profile saved", false);
|
|
});
|
|
|
|
// Geolocation
|
|
document.getElementById("geolocate-btn").addEventListener("click", () => {
|
|
const btn = document.getElementById("geolocate-btn");
|
|
btn.disabled = true;
|
|
btn.textContent = "\u27f3 Detecting\u2026";
|
|
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 = "\ud83d\udccd 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 = "\ud83d\udccd 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;
|
|
}
|
|
// Fetch window counts
|
|
const winCounts = {};
|
|
for (const r of rooms) {
|
|
const wins = await dbGetByIndex("windows", "roomId", r.id);
|
|
winCounts[r.id] = wins.length;
|
|
}
|
|
list.replaceChildren();
|
|
for (const r of rooms) {
|
|
const el = cloneTemplate("tpl-room-card");
|
|
const wc = winCounts[r.id] || 0;
|
|
const wcLabel = wc > 0 ? ` \u00b7 ${wc} window${wc > 1 ? 's' : ''}` : '';
|
|
el.querySelector('[data-slot="name"]').textContent = r.name;
|
|
el.querySelector('[data-slot="details"]').textContent = `${r.areaSqm}m\u00b2 \u00b7 ${r.orientation} \u00b7 SHGC ${r.shgc} \u00b7 ${r.indoorTempC || 25}\u00b0C${wcLabel}`;
|
|
el.firstElementChild.dataset.id = r.id;
|
|
list.appendChild(el);
|
|
}
|
|
}
|
|
|
|
async function editRoomUI(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;
|
|
enterEditMode(form);
|
|
form.querySelector('input[name="name"]').focus();
|
|
await loadWindowsForRoom(r.id);
|
|
}
|
|
|
|
async function deleteRoomUI(id) {
|
|
if (!confirm("Delete this room and its devices/occupants?")) return;
|
|
await deleteRoomData(id);
|
|
await loadRooms();
|
|
await refreshRoomSelects();
|
|
await updateTabBadges();
|
|
showToast("Room deleted", false);
|
|
}
|
|
|
|
// Event delegation for rooms list
|
|
document.getElementById("rooms-list").addEventListener("click", async (e) => {
|
|
const btn = e.target.closest("[data-action]");
|
|
if (!btn) return;
|
|
const card = btn.closest("[data-id]");
|
|
if (!card) return;
|
|
const id = parseInt(card.dataset.id);
|
|
if (btn.dataset.action === "edit") await editRoomUI(id);
|
|
else if (btn.dataset.action === "delete") await deleteRoomUI(id);
|
|
});
|
|
|
|
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),
|
|
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),
|
|
};
|
|
let roomId;
|
|
if (data.id) {
|
|
room.id = parseInt(data.id);
|
|
await dbPut("rooms", room);
|
|
roomId = room.id;
|
|
} else {
|
|
roomId = await dbAdd("rooms", room);
|
|
}
|
|
resetForm(e.target);
|
|
await loadRooms();
|
|
await refreshRoomSelects();
|
|
await loadWindowsForRoom(roomId);
|
|
await updateTabBadges();
|
|
showToast("Room saved", false);
|
|
});
|
|
|
|
// ========== Windows ==========
|
|
let _currentWindowRoomId = null;
|
|
|
|
async function loadWindowsForRoom(roomId) {
|
|
_currentWindowRoomId = roomId;
|
|
const section = document.getElementById("windows-section");
|
|
const form = document.getElementById("window-form");
|
|
const saveFirst = document.getElementById("windows-save-first");
|
|
|
|
if (!roomId) {
|
|
section.classList.add("hidden");
|
|
return;
|
|
}
|
|
|
|
section.classList.remove("hidden");
|
|
saveFirst.classList.add("hidden");
|
|
form.classList.remove("hidden");
|
|
form.querySelector('input[name="roomId"]').value = roomId;
|
|
|
|
const windows = await dbGetByIndex("windows", "roomId", roomId);
|
|
const list = document.getElementById("windows-list");
|
|
if (windows.length === 0) {
|
|
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500">No windows. Room-level solar defaults are used.</p>';
|
|
return;
|
|
}
|
|
list.replaceChildren();
|
|
for (const w of windows) {
|
|
const el = cloneTemplate("tpl-window-card");
|
|
el.querySelector('[data-slot="orientation"]').textContent = w.orientation;
|
|
el.querySelector('[data-slot="details"]').textContent = `${w.areaSqm}m\u00b2 \u00b7 SHGC ${w.shgc} \u00b7 ${w.shadingType} (${w.shadingFactor})`;
|
|
el.firstElementChild.dataset.id = w.id;
|
|
list.appendChild(el);
|
|
}
|
|
}
|
|
|
|
async function editWindowUI(id) {
|
|
const w = await dbGet("windows", id);
|
|
if (!w) return;
|
|
const form = document.getElementById("window-form");
|
|
form.querySelector('input[name="id"]').value = w.id;
|
|
form.querySelector('input[name="roomId"]').value = w.roomId;
|
|
form.querySelector('select[name="orientation"]').value = w.orientation || "S";
|
|
form.querySelector('input[name="areaSqm"]').value = w.areaSqm || "";
|
|
form.querySelector('input[name="shgc"]').value = w.shgc || 0.6;
|
|
form.querySelector('select[name="shadingType"]').value = w.shadingType || "none";
|
|
form.querySelector('input[name="shadingFactor"]').value = w.shadingFactor ?? 1.0;
|
|
enterEditMode(form);
|
|
}
|
|
|
|
async function deleteWindowUI(id) {
|
|
await dbDelete("windows", id);
|
|
if (_currentWindowRoomId) await loadWindowsForRoom(_currentWindowRoomId);
|
|
await loadRooms();
|
|
showToast("Window deleted", false);
|
|
}
|
|
|
|
// Event delegation for windows list
|
|
document.getElementById("windows-list").addEventListener("click", async (e) => {
|
|
const btn = e.target.closest("[data-action]");
|
|
if (!btn) return;
|
|
const card = btn.closest("[data-id]");
|
|
if (!card) return;
|
|
const id = parseInt(card.dataset.id);
|
|
if (btn.dataset.action === "edit") await editWindowUI(id);
|
|
else if (btn.dataset.action === "delete") await deleteWindowUI(id);
|
|
});
|
|
|
|
document.getElementById("window-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const data = formData(e.target);
|
|
const roomId = parseInt(data.roomId);
|
|
if (!roomId) { showToast("No room selected", true); return; }
|
|
const win = {
|
|
roomId,
|
|
orientation: data.orientation || "S",
|
|
areaSqm: numOrDefault(data.areaSqm, 1.0),
|
|
shgc: numOrDefault(data.shgc, 0.6),
|
|
shadingType: data.shadingType || "none",
|
|
shadingFactor: numOrDefault(data.shadingFactor, 1.0),
|
|
};
|
|
if (data.id) {
|
|
win.id = parseInt(data.id);
|
|
await dbPut("windows", win);
|
|
} else {
|
|
await dbAdd("windows", win);
|
|
}
|
|
resetForm(e.target);
|
|
e.target.querySelector('input[name="roomId"]').value = roomId;
|
|
await loadWindowsForRoom(roomId);
|
|
await loadRooms();
|
|
showToast("Window 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.replaceChildren();
|
|
for (const d of allDevices) {
|
|
const el = cloneTemplate("tpl-device-card");
|
|
el.querySelector('[data-slot="name"]').textContent = d.name;
|
|
el.querySelector('[data-slot="details"]').textContent = `${d._roomName} \u00b7 ${d.wattsTypical}W typical`;
|
|
el.firstElementChild.dataset.id = d.id;
|
|
list.appendChild(el);
|
|
}
|
|
}
|
|
|
|
async function editDeviceUI(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;
|
|
enterEditMode(form);
|
|
form.querySelector('input[name="name"]').focus();
|
|
}
|
|
|
|
async function deleteDeviceUI(id) {
|
|
await dbDelete("devices", id);
|
|
await loadDevices();
|
|
await updateTabBadges();
|
|
showToast("Device deleted", false);
|
|
}
|
|
|
|
// Event delegation for devices list
|
|
document.getElementById("devices-list").addEventListener("click", async (e) => {
|
|
const btn = e.target.closest("[data-action]");
|
|
if (!btn) return;
|
|
const card = btn.closest("[data-id]");
|
|
if (!card) return;
|
|
const id = parseInt(card.dataset.id);
|
|
if (btn.dataset.action === "edit") await editDeviceUI(id);
|
|
else if (btn.dataset.action === "delete") await deleteDeviceUI(id);
|
|
});
|
|
|
|
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();
|
|
await updateTabBadges();
|
|
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.replaceChildren();
|
|
for (const o of allOccupants) {
|
|
const el = cloneTemplate("tpl-occupant-card");
|
|
el.querySelector('[data-slot="count-activity"]').textContent = `${o.count}x ${o.activityLevel}`;
|
|
el.querySelector('[data-slot="details"]').textContent = `${o._roomName}${o.vulnerable ? ' \u00b7 \u26a0 vulnerable' : ''}`;
|
|
el.firstElementChild.dataset.id = o.id;
|
|
list.appendChild(el);
|
|
}
|
|
}
|
|
|
|
async function editOccupantUI(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;
|
|
enterEditMode(form);
|
|
form.querySelector('input[name="count"]').focus();
|
|
}
|
|
|
|
async function deleteOccupantUI(id) {
|
|
await dbDelete("occupants", id);
|
|
await loadOccupants();
|
|
await updateTabBadges();
|
|
showToast("Occupant deleted", false);
|
|
}
|
|
|
|
// Event delegation for occupants list
|
|
document.getElementById("occupants-list").addEventListener("click", async (e) => {
|
|
const btn = e.target.closest("[data-action]");
|
|
if (!btn) return;
|
|
const card = btn.closest("[data-id]");
|
|
if (!card) return;
|
|
const id = parseInt(card.dataset.id);
|
|
if (btn.dataset.action === "edit") await editOccupantUI(id);
|
|
else if (btn.dataset.action === "delete") await deleteOccupantUI(id);
|
|
});
|
|
|
|
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();
|
|
await updateTabBadges();
|
|
showToast("Occupant saved", false);
|
|
});
|
|
|
|
// ========== Unit Switcher ==========
|
|
const BTU_PER_KW = 3412.14;
|
|
let _capacityUnit = "btuh"; // "btuh" or "kw"
|
|
|
|
async function loadCapacityUnit() {
|
|
const saved = await getSetting("capacityUnit");
|
|
if (saved === "kw" || saved === "btuh") _capacityUnit = saved;
|
|
updateUnitToggleUI();
|
|
}
|
|
|
|
function updateUnitToggleUI() {
|
|
const btn = document.getElementById("ac-unit-toggle");
|
|
if (!btn) return;
|
|
btn.dataset.unit = _capacityUnit;
|
|
btn.textContent = _capacityUnit === "kw" ? "kW" : "BTU/h";
|
|
if (_capacityUnit === "kw") {
|
|
btn.classList.add("bg-orange-600", "text-white", "border-orange-600");
|
|
btn.classList.remove("border-gray-300", "dark:border-gray-600");
|
|
} else {
|
|
btn.classList.remove("bg-orange-600", "text-white", "border-orange-600");
|
|
btn.classList.add("border-gray-300", "dark:border-gray-600");
|
|
}
|
|
updateCapacityLabels();
|
|
}
|
|
|
|
function updateCapacityLabels() {
|
|
const suffix = _capacityUnit === "kw" ? " (kW)" : " (BTU)";
|
|
const capLabel = document.getElementById("ac-capacity-label");
|
|
const heatLabel = document.getElementById("ac-heating-capacity-label");
|
|
if (capLabel) {
|
|
const tooltip = capLabel.querySelector(".tooltip-trigger");
|
|
capLabel.textContent = "";
|
|
capLabel.append((_capacityUnit === "kw" ? "Capacity (kW) " : "Capacity (BTU) "));
|
|
if (tooltip) capLabel.appendChild(tooltip);
|
|
}
|
|
if (heatLabel) {
|
|
const tooltip = heatLabel.querySelector(".tooltip-trigger");
|
|
heatLabel.textContent = "";
|
|
heatLabel.append((_capacityUnit === "kw" ? "Heating Capacity (kW) " : "Heating Capacity (BTU) "));
|
|
if (tooltip) heatLabel.appendChild(tooltip);
|
|
}
|
|
}
|
|
|
|
function displayToUnit(btuValue) {
|
|
if (_capacityUnit === "kw") return +(btuValue / BTU_PER_KW).toFixed(2);
|
|
return btuValue;
|
|
}
|
|
|
|
function inputToBtu(inputValue) {
|
|
if (_capacityUnit === "kw") return +(inputValue * BTU_PER_KW).toFixed(0);
|
|
return inputValue;
|
|
}
|
|
|
|
function formatCapacity(btuValue) {
|
|
if (_capacityUnit === "kw") return (btuValue / BTU_PER_KW).toFixed(2) + " kW";
|
|
return btuValue.toFixed(0) + " BTU";
|
|
}
|
|
|
|
const unitToggle = document.getElementById("ac-unit-toggle");
|
|
if (unitToggle) {
|
|
unitToggle.addEventListener("click", async () => {
|
|
// Convert currently displayed values before switching
|
|
const capInput = document.querySelector('#ac-form input[name="capacityBtu"]');
|
|
const heatInput = document.querySelector('#ac-form input[name="heatingCapacityBtu"]');
|
|
|
|
const oldUnit = _capacityUnit;
|
|
_capacityUnit = _capacityUnit === "btuh" ? "kw" : "btuh";
|
|
await setSetting("capacityUnit", _capacityUnit);
|
|
|
|
// Convert displayed values
|
|
if (capInput && capInput.value) {
|
|
const raw = parseFloat(capInput.value);
|
|
if (!isNaN(raw)) {
|
|
if (oldUnit === "btuh" && _capacityUnit === "kw") capInput.value = (raw / BTU_PER_KW).toFixed(2);
|
|
else if (oldUnit === "kw" && _capacityUnit === "btuh") capInput.value = Math.round(raw * BTU_PER_KW);
|
|
}
|
|
}
|
|
if (heatInput && heatInput.value) {
|
|
const raw = parseFloat(heatInput.value);
|
|
if (!isNaN(raw)) {
|
|
if (oldUnit === "btuh" && _capacityUnit === "kw") heatInput.value = (raw / BTU_PER_KW).toFixed(2);
|
|
else if (oldUnit === "kw" && _capacityUnit === "btuh") heatInput.value = Math.round(raw * BTU_PER_KW);
|
|
}
|
|
}
|
|
|
|
updateUnitToggleUI();
|
|
await loadACUnits();
|
|
});
|
|
}
|
|
|
|
// ========== Device Search ==========
|
|
let _searchTimer = null;
|
|
const searchInput = document.getElementById("ac-device-search");
|
|
const searchResults = document.getElementById("ac-search-results");
|
|
|
|
if (searchInput) {
|
|
searchInput.addEventListener("input", () => {
|
|
if (_searchTimer) clearTimeout(_searchTimer);
|
|
const q = searchInput.value.trim();
|
|
if (q.length < 3) {
|
|
searchResults.classList.add("hidden");
|
|
return;
|
|
}
|
|
_searchTimer = setTimeout(() => doDeviceSearch(q), 300);
|
|
});
|
|
|
|
// Close dropdown on outside click
|
|
document.addEventListener("click", (e) => {
|
|
if (!e.target.closest("#ac-device-search") && !e.target.closest("#ac-search-results")) {
|
|
searchResults.classList.add("hidden");
|
|
}
|
|
});
|
|
}
|
|
|
|
async function doDeviceSearch(query) {
|
|
try {
|
|
const resp = await fetch(`/api/bettervent/search?q=${encodeURIComponent(query)}`);
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
renderSearchResults(data.devices || []);
|
|
} catch (e) {
|
|
console.error("Device search error:", e);
|
|
}
|
|
}
|
|
|
|
function renderSearchResults(devices) {
|
|
searchResults.replaceChildren();
|
|
if (devices.length === 0) {
|
|
const p = document.createElement("div");
|
|
p.className = "px-3 py-2 text-sm text-gray-400";
|
|
p.textContent = "No devices found";
|
|
searchResults.appendChild(p);
|
|
searchResults.classList.remove("hidden");
|
|
return;
|
|
}
|
|
for (const d of devices) {
|
|
const item = document.createElement("div");
|
|
item.className = "px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0";
|
|
item.innerHTML = `<div class="text-sm font-medium">${esc(d.tradeName)} — ${esc(d.modelName)}</div>` +
|
|
`<div class="text-xs text-gray-400">${d.coolingKw} kW cool / ${d.heatingKw} kW heat · EER ${d.eer} · ${d.seerClass || ""}</div>`;
|
|
item.addEventListener("click", () => selectDevice(d));
|
|
searchResults.appendChild(item);
|
|
}
|
|
searchResults.classList.remove("hidden");
|
|
}
|
|
|
|
function mapMountingToType(mounting) {
|
|
const m = (mounting || "").toLowerCase();
|
|
if (m.includes("wall")) return "split";
|
|
if (m.includes("floor")) return "portable";
|
|
if (m.includes("ceiling") || m.includes("cassette")) return "central";
|
|
if (m.includes("ducted")) return "central";
|
|
return "split";
|
|
}
|
|
|
|
function selectDevice(d) {
|
|
const form = document.getElementById("ac-form");
|
|
form.querySelector('input[name="name"]').value = d.tradeName + " " + d.modelName;
|
|
form.querySelector('select[name="acType"]').value = mapMountingToType(d.mounting);
|
|
|
|
// Capacity: convert kW to appropriate unit
|
|
const coolBtu = d.coolingKw * BTU_PER_KW;
|
|
const heatBtu = d.heatingKw * BTU_PER_KW;
|
|
form.querySelector('input[name="capacityBtu"]').value = _capacityUnit === "kw" ? d.coolingKw.toFixed(2) : Math.round(coolBtu);
|
|
form.querySelector('input[name="efficiencyEer"]').value = d.eer || 10;
|
|
|
|
const canHeatCb = form.querySelector('input[name="canHeat"]');
|
|
canHeatCb.checked = d.heatingKw > 0;
|
|
form.querySelector('input[name="heatingCapacityBtu"]').value = _capacityUnit === "kw" ? d.heatingKw.toFixed(2) : Math.round(heatBtu);
|
|
|
|
// Store extended fields in form dataset for later save
|
|
form.dataset.seer = d.seer || "";
|
|
form.dataset.seerClass = d.seerClass || "";
|
|
form.dataset.scop = d.scop || "";
|
|
form.dataset.scopClass = d.scopClass || "";
|
|
form.dataset.cop = d.cop || "";
|
|
form.dataset.tol = d.tol || "";
|
|
form.dataset.tbiv = d.tbiv || "";
|
|
form.dataset.refrigerant = d.refrigerant || "";
|
|
|
|
showExtendedInfo(d);
|
|
|
|
// Clear search
|
|
searchInput.value = "";
|
|
searchResults.classList.add("hidden");
|
|
}
|
|
|
|
function showExtendedInfo(d) {
|
|
const el = document.getElementById("ac-extended-info");
|
|
document.getElementById("ac-extended-title").textContent = (d.tradeName || "") + " " + (d.modelName || d.name || "");
|
|
document.getElementById("ac-ext-seer").textContent = d.seer ? `${d.seer} (${d.seerClass || ""})` : "—";
|
|
document.getElementById("ac-ext-scop").textContent = d.scop ? `${d.scop} (${d.scopClass || ""})` : "—";
|
|
document.getElementById("ac-ext-cop").textContent = d.cop || "—";
|
|
document.getElementById("ac-ext-tol").textContent = d.tol ? `${d.tol}\u00b0C` : "—";
|
|
document.getElementById("ac-ext-tbiv").textContent = d.tbiv ? `${d.tbiv}\u00b0C` : "—";
|
|
document.getElementById("ac-ext-refrigerant").textContent = d.refrigerant || "—";
|
|
el.classList.remove("hidden");
|
|
}
|
|
|
|
function hideExtendedInfo() {
|
|
document.getElementById("ac-extended-info").classList.add("hidden");
|
|
}
|
|
|
|
function clearACExtendedData() {
|
|
const form = document.getElementById("ac-form");
|
|
delete form.dataset.seer;
|
|
delete form.dataset.seerClass;
|
|
delete form.dataset.scop;
|
|
delete form.dataset.scopClass;
|
|
delete form.dataset.cop;
|
|
delete form.dataset.tol;
|
|
delete form.dataset.tbiv;
|
|
delete form.dataset.refrigerant;
|
|
hideExtendedInfo();
|
|
}
|
|
|
|
function esc(s) {
|
|
if (!s) return "";
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
// ========== 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.replaceChildren();
|
|
for (const u of units) {
|
|
const el = cloneTemplate("tpl-ac-card");
|
|
const roomIds = assignments.filter(a => a.acId === u.id).map(a => a.roomId);
|
|
const roomNames = roomIds.map(id => roomMap[id] || `Room ${id}`).join(", ");
|
|
el.querySelector('[data-slot="name"]').textContent = u.name;
|
|
const capStr = formatCapacity(u.capacityBtu);
|
|
const heatInfo = u.canHeat ? ` \u00b7 Heat ${formatCapacity(u.heatingCapacityBtu || u.capacityBtu)}` : '';
|
|
const seerInfo = u.seer ? ` \u00b7 SEER ${u.seer}` : '';
|
|
el.querySelector('[data-slot="details"]').textContent = `${capStr} \u00b7 ${u.acType}${heatInfo}${seerInfo}${roomNames ? ' \u00b7 ' + roomNames : ''}`;
|
|
el.firstElementChild.dataset.id = u.id;
|
|
list.appendChild(el);
|
|
}
|
|
}
|
|
|
|
async function editACUI(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 = displayToUnit(u.capacityBtu || 0);
|
|
form.querySelector('input[name="efficiencyEer"]').value = u.efficiencyEer || 10;
|
|
form.querySelector('input[name="hasDehumidify"]').checked = !!u.hasDehumidify;
|
|
form.querySelector('input[name="canHeat"]').checked = !!u.canHeat;
|
|
form.querySelector('input[name="heatingCapacityBtu"]').value = displayToUnit(u.heatingCapacityBtu || 0);
|
|
// Extended fields
|
|
form.dataset.seer = u.seer || "";
|
|
form.dataset.seerClass = u.seerClass || "";
|
|
form.dataset.scop = u.scop || "";
|
|
form.dataset.scopClass = u.scopClass || "";
|
|
form.dataset.cop = u.cop || "";
|
|
form.dataset.tol = u.tol || "";
|
|
form.dataset.tbiv = u.tbiv || "";
|
|
form.dataset.refrigerant = u.refrigerant || "";
|
|
if (u.seer || u.scop || u.refrigerant) {
|
|
showExtendedInfo(u);
|
|
} else {
|
|
hideExtendedInfo();
|
|
}
|
|
// 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));
|
|
});
|
|
enterEditMode(form);
|
|
form.querySelector('input[name="name"]').focus();
|
|
}
|
|
|
|
async function deleteACUI(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();
|
|
await updateTabBadges();
|
|
showToast("AC unit deleted", false);
|
|
}
|
|
|
|
// Event delegation for AC list
|
|
document.getElementById("ac-list").addEventListener("click", async (e) => {
|
|
const btn = e.target.closest("[data-action]");
|
|
if (!btn) return;
|
|
const card = btn.closest("[data-id]");
|
|
if (!card) return;
|
|
const id = parseInt(card.dataset.id);
|
|
if (btn.dataset.action === "edit") await editACUI(id);
|
|
else if (btn.dataset.action === "delete") await deleteACUI(id);
|
|
});
|
|
|
|
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 rawCap = numOrDefault(data.capacityBtu, 0);
|
|
const rawHeat = numOrDefault(data.heatingCapacityBtu, 0);
|
|
const unit = {
|
|
profileId,
|
|
name: data.name,
|
|
acType: data.acType || "portable",
|
|
capacityBtu: inputToBtu(rawCap),
|
|
efficiencyEer: numOrDefault(data.efficiencyEer, 10),
|
|
hasDehumidify: !!data.hasDehumidify,
|
|
canHeat: !!data.canHeat,
|
|
heatingCapacityBtu: inputToBtu(rawHeat),
|
|
};
|
|
// Extended fields from form dataset
|
|
const form = e.target;
|
|
if (form.dataset.seer) unit.seer = parseFloat(form.dataset.seer) || 0;
|
|
if (form.dataset.seerClass) unit.seerClass = form.dataset.seerClass;
|
|
if (form.dataset.scop) unit.scop = parseFloat(form.dataset.scop) || 0;
|
|
if (form.dataset.scopClass) unit.scopClass = form.dataset.scopClass;
|
|
if (form.dataset.cop) unit.cop = parseFloat(form.dataset.cop) || 0;
|
|
if (form.dataset.tol) unit.tol = parseFloat(form.dataset.tol) || 0;
|
|
if (form.dataset.tbiv) unit.tbiv = parseFloat(form.dataset.tbiv) || 0;
|
|
if (form.dataset.refrigerant) unit.refrigerant = form.dataset.refrigerant;
|
|
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);
|
|
clearACExtendedData();
|
|
await loadACUnits();
|
|
await updateTabBadges();
|
|
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 ==========
|
|
async function loadForecastConfig() {
|
|
const provider = await getSetting("forecastProvider");
|
|
const apiKey = await getSetting("forecastApiKey");
|
|
if (provider) document.getElementById("forecast-provider-select").value = provider;
|
|
if (apiKey) document.getElementById("forecast-api-key").value = apiKey;
|
|
toggleForecastApiKeyField(provider);
|
|
}
|
|
|
|
function toggleForecastApiKeyField(provider) {
|
|
const group = document.getElementById("forecast-apikey-group");
|
|
const hint = document.getElementById("forecast-provider-hint");
|
|
if (provider === "openweathermap") {
|
|
group.classList.remove("hidden");
|
|
hint.innerHTML = '<a href="https://openweathermap.org/api/one-call-3" target="_blank" rel="noopener" class="text-orange-600 hover:underline">Requires One Call 3.0 subscription \u2192</a>';
|
|
} else {
|
|
group.classList.add("hidden");
|
|
hint.textContent = "";
|
|
}
|
|
}
|
|
|
|
document.getElementById("forecast-provider-select").addEventListener("change", (e) => {
|
|
toggleForecastApiKeyField(e.target.value);
|
|
});
|
|
|
|
document.getElementById("forecast-config-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const provider = document.getElementById("forecast-provider-select").value;
|
|
const apiKey = document.getElementById("forecast-api-key").value;
|
|
await setSetting("forecastProvider", provider);
|
|
await setSetting("forecastApiKey", apiKey);
|
|
showToast("Forecast settings saved", false);
|
|
});
|
|
|
|
document.getElementById("fetch-forecast-btn").addEventListener("click", async () => {
|
|
const profileId = await getActiveProfileId();
|
|
if (!profileId) { showToast("Select a profile first", true); return; }
|
|
|
|
const btn = document.getElementById("fetch-forecast-btn");
|
|
const spinner = document.getElementById("forecast-spinner");
|
|
btn.disabled = true;
|
|
spinner.classList.remove("hidden");
|
|
|
|
try {
|
|
await fetchForecastForProfile(profileId);
|
|
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 hasRooms = rooms.length > 0;
|
|
|
|
["device-room-select", "occupant-room-select"].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.replaceChildren();
|
|
if (hasRooms) {
|
|
for (const r of rooms) {
|
|
const opt = cloneTemplate("tpl-room-option").firstElementChild;
|
|
opt.value = r.id;
|
|
opt.textContent = r.name;
|
|
el.appendChild(opt);
|
|
}
|
|
} else {
|
|
const opt = document.createElement("option");
|
|
opt.value = "";
|
|
opt.textContent = "\u2014";
|
|
el.appendChild(opt);
|
|
}
|
|
el.disabled = !hasRooms;
|
|
});
|
|
|
|
// Show/hide no-rooms warnings and disable submit
|
|
const noRoomIds = ["device-no-rooms", "occupant-no-rooms", "ac-no-rooms"];
|
|
const formIds = ["device-form", "occupant-form", "ac-form"];
|
|
noRoomIds.forEach((id, i) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.classList.toggle("hidden", hasRooms);
|
|
const form = document.getElementById(formIds[i]);
|
|
if (form) {
|
|
const btn = form.querySelector(".submit-btn");
|
|
if (btn) btn.disabled = !hasRooms;
|
|
}
|
|
});
|
|
|
|
// AC room checkboxes
|
|
const acCheckboxes = document.getElementById("ac-room-checkboxes");
|
|
if (acCheckboxes) {
|
|
acCheckboxes.replaceChildren();
|
|
for (const r of rooms) {
|
|
const el = cloneTemplate("tpl-ac-room-checkbox");
|
|
el.querySelector('input').value = r.id;
|
|
el.querySelector('[data-slot="name"]').textContent = r.name;
|
|
acCheckboxes.appendChild(el);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========== Tab Badges ==========
|
|
async function updateTabBadges() {
|
|
const profileId = await getActiveProfileId();
|
|
if (!profileId) return;
|
|
|
|
const profiles = await dbGetAll("profiles");
|
|
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
|
let deviceCount = 0, occupantCount = 0;
|
|
for (const r of rooms) {
|
|
const devs = await dbGetByIndex("devices", "roomId", r.id);
|
|
deviceCount += devs.length;
|
|
const occs = await dbGetByIndex("occupants", "roomId", r.id);
|
|
occupantCount += occs.length;
|
|
}
|
|
const acUnits = await dbGetByIndex("ac_units", "profileId", profileId);
|
|
|
|
const badges = {
|
|
profiles: profiles.length,
|
|
rooms: rooms.length,
|
|
devices: deviceCount,
|
|
occupants: occupantCount,
|
|
ac: acUnits.length,
|
|
};
|
|
|
|
tabBtns.forEach(btn => {
|
|
const tab = btn.dataset.tab;
|
|
const count = badges[tab];
|
|
let badge = btn.querySelector(".tab-badge");
|
|
if (count !== undefined && count > 0) {
|
|
if (!badge) {
|
|
badge = document.createElement("span");
|
|
badge.className = "tab-badge ml-1 text-xs bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300 px-1.5 py-0.5 rounded-full";
|
|
btn.appendChild(badge);
|
|
}
|
|
badge.textContent = count;
|
|
} else if (badge) {
|
|
badge.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ========== Init ==========
|
|
async function init() {
|
|
await loadCapacityUnit();
|
|
await loadProfiles();
|
|
await loadRooms();
|
|
await loadDevices();
|
|
await loadOccupants();
|
|
await loadACUnits();
|
|
await loadToggles();
|
|
await loadForecastConfig();
|
|
await loadForecastStatus();
|
|
await loadLLMConfig();
|
|
await refreshRoomSelects();
|
|
await updateTabBadges();
|
|
}
|
|
|
|
init();
|
|
})();
|