Replace Unicode edit/delete characters with SVG pencil/trash icon buttons, add edit mode visual feedback (orange ring + Save/Cancel buttons), group action buttons consistently across all entity types, add hover effects to list items, and constrain all pages to 1800px max-width via layout template.
732 lines
28 KiB
JavaScript
732 lines
28 KiB
JavaScript
// Setup page logic
|
|
(function() {
|
|
"use strict";
|
|
|
|
// SVG icon templates
|
|
const iconEdit = '<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 3a2.83 2.83 0 114 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>';
|
|
const iconDelete = '<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>';
|
|
|
|
// 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 `<button onclick="${onclick}" class="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/30 transition" title="Edit">${iconEdit}</button>`;
|
|
}
|
|
|
|
function deleteBtn(onclick) {
|
|
return `<button onclick="${onclick}" class="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 transition" title="Delete">${iconDelete}</button>`;
|
|
}
|
|
|
|
function actionGroup(editOnclick, deleteOnclick) {
|
|
return `<div class="flex items-center gap-1">${editBtn(editOnclick)}${deleteBtn(deleteOnclick)}</div>`;
|
|
}
|
|
|
|
// ========== Profiles ==========
|
|
async function loadProfiles() {
|
|
const profiles = await dbGetAll("profiles");
|
|
const list = document.getElementById("profiles-list");
|
|
if (profiles.length === 0) {
|
|
list.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500">No profiles yet.</p>';
|
|
return;
|
|
}
|
|
const activeId = await getActiveProfileId();
|
|
list.innerHTML = profiles.map(p => {
|
|
const isActive = activeId === p.id;
|
|
return `
|
|
<div class="${cardClasses}">
|
|
<div>
|
|
<span class="font-medium">${esc(p.name)}</span>
|
|
<span class="text-xs text-gray-400 ml-2">${p.latitude.toFixed(4)}, ${p.longitude.toFixed(4)} · ${esc(p.timezone || "")}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<button onclick="activateProfile(${p.id})" class="text-xs px-2 py-1 rounded ${isActive ? 'bg-orange-600 text-white' : 'bg-gray-100 dark:bg-gray-700'}">
|
|
${isActive ? '● Active' : 'Set Active'}
|
|
</button>
|
|
${actionGroup(`editProfileUI(${p.id})`, `deleteProfileUI(${p.id})`)}
|
|
</div>
|
|
</div>`;
|
|
}).join("");
|
|
}
|
|
|
|
window.activateProfile = async function(id) {
|
|
await setActiveProfileId(id);
|
|
await loadProfiles();
|
|
await refreshRoomSelects();
|
|
showToast("Profile activated", false);
|
|
};
|
|
|
|
window.editProfileUI = async function(id) {
|
|
const p = await dbGet("profiles", id);
|
|
if (!p) return;
|
|
const form = document.getElementById("profile-form");
|
|
form.querySelector('input[name="id"]').value = p.id;
|
|
form.querySelector('input[name="name"]').value = p.name;
|
|
form.querySelector('input[name="latitude"]').value = p.latitude;
|
|
form.querySelector('input[name="longitude"]').value = p.longitude;
|
|
form.querySelector('input[name="timezone"]').value = p.timezone || "Europe/Berlin";
|
|
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 = '<p class="text-sm text-gray-400">No rooms yet.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = rooms.map(r => `
|
|
<div class="${cardClasses}">
|
|
<div>
|
|
<span class="font-medium">${esc(r.name)}</span>
|
|
<span class="text-xs text-gray-400 ml-2">${r.areaSqm}m² · ${r.orientation} · SHGC ${r.shgc} · ${r.indoorTempC || 25}°C</span>
|
|
</div>
|
|
${actionGroup(`editRoomUI(${r.id})`, `deleteRoomUI(${r.id})`)}
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
window.editRoomUI = async function(id) {
|
|
const r = await dbGet("rooms", id);
|
|
if (!r) return;
|
|
const form = document.getElementById("room-form");
|
|
form.querySelector('input[name="id"]').value = r.id;
|
|
form.querySelector('input[name="name"]').value = r.name;
|
|
form.querySelector('input[name="areaSqm"]').value = r.areaSqm || "";
|
|
form.querySelector('input[name="ceilingHeightM"]').value = r.ceilingHeightM || 2.5;
|
|
form.querySelector('input[name="floor"]').value = r.floor || 0;
|
|
form.querySelector('select[name="orientation"]').value = r.orientation || "S";
|
|
form.querySelector('select[name="shadingType"]').value = r.shadingType || "none";
|
|
form.querySelector('input[name="shadingFactor"]').value = r.shadingFactor ?? 1.0;
|
|
form.querySelector('input[name="ventilationAch"]').value = r.ventilationAch || 0.5;
|
|
form.querySelector('input[name="windowFraction"]').value = r.windowFraction || 0.15;
|
|
form.querySelector('input[name="shgc"]').value = r.shgc || 0.6;
|
|
form.querySelector('select[name="insulation"]').value = r.insulation || "average";
|
|
form.querySelector('input[name="indoorTempC"]').value = r.indoorTempC || 25;
|
|
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 = '<p class="text-sm text-gray-400">No devices yet.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = allDevices.map(d => `
|
|
<div class="${cardClasses}">
|
|
<div>
|
|
<span class="font-medium">${esc(d.name)}</span>
|
|
<span class="text-xs text-gray-400 ml-2">${esc(d._roomName)} · ${d.wattsTypical}W typical</span>
|
|
</div>
|
|
${actionGroup(`editDeviceUI(${d.id})`, `deleteDeviceUI(${d.id})`)}
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
window.editDeviceUI = async function(id) {
|
|
const d = await dbGet("devices", id);
|
|
if (!d) return;
|
|
const form = document.getElementById("device-form");
|
|
form.querySelector('input[name="id"]').value = d.id;
|
|
form.querySelector('select[name="roomId"]').value = d.roomId;
|
|
form.querySelector('input[name="name"]').value = d.name;
|
|
form.querySelector('input[name="deviceType"]').value = d.deviceType || "electronics";
|
|
form.querySelector('input[name="wattsIdle"]').value = d.wattsIdle || 0;
|
|
form.querySelector('input[name="wattsTypical"]').value = d.wattsTypical || 0;
|
|
form.querySelector('input[name="wattsPeak"]').value = d.wattsPeak || 0;
|
|
form.querySelector('input[name="dutyCycle"]').value = d.dutyCycle ?? 1.0;
|
|
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 = '<p class="text-sm text-gray-400">No occupants yet.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = allOccupants.map(o => `
|
|
<div class="${cardClasses}">
|
|
<div>
|
|
<span class="font-medium">${o.count}x ${esc(o.activityLevel)}</span>
|
|
<span class="text-xs text-gray-400 ml-2">${esc(o._roomName)}${o.vulnerable ? ' · ⚠ vulnerable' : ''}</span>
|
|
</div>
|
|
${actionGroup(`editOccupantUI(${o.id})`, `deleteOccupantUI(${o.id})`)}
|
|
</div>
|
|
`).join("");
|
|
}
|
|
|
|
window.editOccupantUI = async function(id) {
|
|
const o = await dbGet("occupants", id);
|
|
if (!o) return;
|
|
const form = document.getElementById("occupant-form");
|
|
form.querySelector('input[name="id"]').value = o.id;
|
|
form.querySelector('select[name="roomId"]').value = o.roomId;
|
|
form.querySelector('input[name="count"]').value = o.count || 1;
|
|
form.querySelector('select[name="activityLevel"]').value = o.activityLevel || "sedentary";
|
|
form.querySelector('input[name="vulnerable"]').checked = !!o.vulnerable;
|
|
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 = '<p class="text-sm text-gray-400">No AC units yet.</p>';
|
|
return;
|
|
}
|
|
const assignments = await dbGetAll("ac_assignments");
|
|
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
|
const roomMap = Object.fromEntries(rooms.map(r => [r.id, r.name]));
|
|
|
|
list.innerHTML = units.map(u => {
|
|
const roomIds = assignments.filter(a => a.acId === u.id).map(a => a.roomId);
|
|
const roomNames = roomIds.map(id => roomMap[id] || `Room ${id}`).join(", ");
|
|
return `
|
|
<div class="${cardClasses}">
|
|
<div>
|
|
<span class="font-medium">${esc(u.name)}</span>
|
|
<span class="text-xs text-gray-400 ml-2">${u.capacityBtu} BTU · ${esc(u.acType)}${roomNames ? ' · ' + esc(roomNames) : ''}</span>
|
|
</div>
|
|
${actionGroup(`editACUI(${u.id})`, `deleteACUI(${u.id})`)}
|
|
</div>
|
|
`;
|
|
}).join("");
|
|
}
|
|
|
|
window.editACUI = async function(id) {
|
|
const u = await dbGet("ac_units", id);
|
|
if (!u) return;
|
|
const form = document.getElementById("ac-form");
|
|
form.querySelector('input[name="id"]').value = u.id;
|
|
form.querySelector('input[name="name"]').value = u.name;
|
|
form.querySelector('select[name="acType"]').value = u.acType || "portable";
|
|
form.querySelector('input[name="capacityBtu"]').value = u.capacityBtu || 0;
|
|
form.querySelector('input[name="efficiencyEer"]').value = u.efficiencyEer || 10;
|
|
form.querySelector('input[name="hasDehumidify"]').checked = !!u.hasDehumidify;
|
|
// Check assigned rooms
|
|
const assignments = await dbGetAll("ac_assignments");
|
|
const assignedRoomIds = new Set(assignments.filter(a => a.acId === id).map(a => a.roomId));
|
|
document.querySelectorAll('#ac-room-checkboxes input').forEach(cb => {
|
|
cb.checked = assignedRoomIds.has(parseInt(cb.value));
|
|
});
|
|
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 profiles = await dbGetAll("profiles");
|
|
const profile = profiles.find(p => p.id === profileId);
|
|
if (!profile) return;
|
|
|
|
const btn = document.getElementById("fetch-forecast-btn");
|
|
const spinner = document.getElementById("forecast-spinner");
|
|
btn.disabled = true;
|
|
spinner.classList.remove("hidden");
|
|
|
|
try {
|
|
const resp = await fetch("/api/weather/forecast", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
lat: profile.latitude,
|
|
lon: profile.longitude,
|
|
timezone: profile.timezone || "Europe/Berlin",
|
|
}),
|
|
});
|
|
if (!resp.ok) throw new Error(await resp.text());
|
|
const data = await resp.json();
|
|
|
|
// Clear old forecasts for this profile
|
|
await deleteByIndex("forecasts", "profileId", profileId);
|
|
|
|
// Store hourly forecasts
|
|
for (const h of (data.Hourly || data.hourly || [])) {
|
|
await dbAdd("forecasts", {
|
|
profileId,
|
|
timestamp: h.Timestamp || h.timestamp,
|
|
temperatureC: h.TemperatureC ?? h.temperatureC ?? null,
|
|
humidityPct: h.HumidityPct ?? h.humidityPct ?? null,
|
|
cloudCoverPct: h.CloudCoverPct ?? h.cloudCoverPct ?? null,
|
|
sunshineMin: h.SunshineMin ?? h.sunshineMin ?? null,
|
|
apparentTempC: h.ApparentTempC ?? h.apparentTempC ?? null,
|
|
});
|
|
}
|
|
|
|
// Also fetch warnings
|
|
try {
|
|
const wResp = await fetch("/api/weather/warnings", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ lat: profile.latitude, lon: profile.longitude }),
|
|
});
|
|
if (wResp.ok) {
|
|
const wData = await wResp.json();
|
|
await deleteByIndex("warnings", "profileId", profileId);
|
|
for (const w of (wData.warnings || [])) {
|
|
await dbAdd("warnings", {
|
|
profileId,
|
|
headline: w.Headline || w.headline || "",
|
|
severity: w.Severity || w.severity || "",
|
|
description: w.Description || w.description || "",
|
|
instruction: w.Instruction || w.instruction || "",
|
|
onset: w.Onset || w.onset || "",
|
|
expires: w.Expires || w.expires || "",
|
|
});
|
|
}
|
|
}
|
|
} catch (e) { /* warnings are optional */ }
|
|
|
|
await setSetting("lastFetched", new Date().toISOString());
|
|
document.getElementById("last-fetched").textContent = new Date().toLocaleString();
|
|
showToast("Forecast fetched", false);
|
|
} catch (err) {
|
|
showToast("Fetch failed: " + err.message, true);
|
|
} finally {
|
|
btn.disabled = false;
|
|
spinner.classList.add("hidden");
|
|
}
|
|
});
|
|
|
|
async function loadForecastStatus() {
|
|
const lastFetched = await getSetting("lastFetched");
|
|
if (lastFetched) {
|
|
document.getElementById("last-fetched").textContent = new Date(lastFetched).toLocaleString();
|
|
}
|
|
}
|
|
|
|
// ========== LLM Config ==========
|
|
async function loadLLMConfig() {
|
|
// Show server-side info
|
|
try {
|
|
const resp = await fetch("/api/llm/config");
|
|
const data = await resp.json();
|
|
const infoEl = document.getElementById("llm-server-info");
|
|
if (data.available) {
|
|
infoEl.textContent = `Server: ${data.provider} (${data.model || "default"})`;
|
|
infoEl.classList.remove("hidden");
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
|
|
// Load saved client-side LLM settings
|
|
const provider = await getSetting("llmProvider");
|
|
const apiKey = await getSetting("llmApiKey");
|
|
const model = await getSetting("llmModel");
|
|
|
|
if (provider) document.getElementById("llm-provider-select").value = provider;
|
|
if (apiKey) document.getElementById("llm-api-key").value = apiKey;
|
|
if (model) document.getElementById("llm-model").value = model;
|
|
}
|
|
|
|
document.getElementById("llm-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const provider = document.getElementById("llm-provider-select").value;
|
|
const apiKey = document.getElementById("llm-api-key").value;
|
|
const model = document.getElementById("llm-model").value;
|
|
|
|
await setSetting("llmProvider", provider);
|
|
await setSetting("llmApiKey", apiKey);
|
|
await setSetting("llmModel", model);
|
|
|
|
showToast("LLM settings saved", false);
|
|
});
|
|
|
|
// ========== Room selects for device/occupant/AC forms ==========
|
|
async function refreshRoomSelects() {
|
|
const profileId = await getActiveProfileId();
|
|
if (!profileId) return;
|
|
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
|
|
const options = rooms.map(r => `<option value="${r.id}">${esc(r.name)}</option>`).join("");
|
|
|
|
["device-room-select", "occupant-room-select"].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.innerHTML = options;
|
|
});
|
|
|
|
// AC room checkboxes
|
|
const acCheckboxes = document.getElementById("ac-room-checkboxes");
|
|
if (acCheckboxes) {
|
|
acCheckboxes.innerHTML = rooms.map(r => `
|
|
<label class="flex items-center gap-1 text-sm">
|
|
<input type="checkbox" value="${r.id}" class="rounded"> ${esc(r.name)}
|
|
</label>
|
|
`).join("");
|
|
}
|
|
}
|
|
|
|
// ========== Utility ==========
|
|
function esc(s) {
|
|
const div = document.createElement("div");
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// ========== Init ==========
|
|
async function init() {
|
|
await loadProfiles();
|
|
await loadRooms();
|
|
await loadDevices();
|
|
await loadOccupants();
|
|
await loadACUnits();
|
|
await loadToggles();
|
|
await loadForecastStatus();
|
|
await loadLLMConfig();
|
|
await refreshRoomSelects();
|
|
}
|
|
|
|
init();
|
|
})();
|