// Setup page logic
(function() {
"use strict";
// SVG icon templates
const iconEdit = '';
const iconDelete = '';
// 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 = "";
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);
});
});
// List item card classes
const cardClasses = "bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200";
// Action button builders
function editBtn(onclick) {
return ``;
}
function deleteBtn(onclick) {
return ``;
}
function actionGroup(editOnclick, deleteOnclick) {
return `
${editBtn(editOnclick)}${deleteBtn(deleteOnclick)}
`;
}
// ========== 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.innerHTML = profiles.map(p => {
const isActive = activeId === p.id;
return `
${esc(p.name)}
${p.latitude.toFixed(4)}, ${p.longitude.toFixed(4)} · ${esc(p.timezone || "")}
${actionGroup(`editProfileUI(${p.id})`, `deleteProfileUI(${p.id})`)}
`;
}).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";
enterEditMode(form);
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 = 'No rooms yet.
';
return;
}
list.innerHTML = rooms.map(r => `
${esc(r.name)}
${r.areaSqm}m² · ${r.orientation} · SHGC ${r.shgc} · ${r.indoorTempC || 25}°C
${actionGroup(`editRoomUI(${r.id})`, `deleteRoomUI(${r.id})`)}
`).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;
enterEditMode(form);
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 = 'No devices yet.
';
return;
}
list.innerHTML = allDevices.map(d => `
${esc(d.name)}
${esc(d._roomName)} · ${d.wattsTypical}W typical
${actionGroup(`editDeviceUI(${d.id})`, `deleteDeviceUI(${d.id})`)}
`).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;
enterEditMode(form);
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 = 'No occupants yet.
';
return;
}
list.innerHTML = allOccupants.map(o => `
${o.count}x ${esc(o.activityLevel)}
${esc(o._roomName)}${o.vulnerable ? ' · ⚠ vulnerable' : ''}
${actionGroup(`editOccupantUI(${o.id})`, `deleteOccupantUI(${o.id})`)}
`).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;
enterEditMode(form);
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 = '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.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 `
${esc(u.name)}
${u.capacityBtu} BTU · ${esc(u.acType)}${roomNames ? ' · ' + esc(roomNames) : ''}
${actionGroup(`editACUI(${u.id})`, `deleteACUI(${u.id})`)}
`;
}).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));
});
enterEditMode(form);
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 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 options = rooms.map(r => ``).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 => `
`).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();
})();