// 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 = '
No profiles yet.
'; 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 = 'No rooms yet.
'; 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 = 'No windows. Room-level solar defaults are used.
'; 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 = 'No devices yet.
'; 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 = 'No occupants yet.
'; 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 = `No AC units yet.
'; 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"); const desc = document.getElementById("forecast-provider-desc"); const ts = window.HG && window.HG.t ? window.HG.t : {}; const providerDesc = (ts.setup && ts.setup.forecast && ts.setup.forecast.providerDesc) || {}; if (provider === "openweathermap") { group.classList.remove("hidden"); hint.innerHTML = 'Requires One Call 3.0 subscription \u2192'; if (desc) desc.textContent = providerDesc.openweathermap || "48h hourly + 8-day daily forecast. Includes UV index and worldwide weather alerts."; } else { group.classList.add("hidden"); hint.textContent = ""; if (desc) desc.textContent = providerDesc.openmeteo || "Free, no API key needed. 3-day hourly forecast (temp, humidity, wind, solar, pressure). Warnings via DWD (Germany only)."; } if (desc) desc.classList.remove("hidden"); } 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(); })();