diff --git a/web/js/dashboard.js b/web/js/dashboard.js
index ea3a52e..16ddbc9 100644
--- a/web/js/dashboard.js
+++ b/web/js/dashboard.js
@@ -13,6 +13,11 @@
function show(id) { $(id).classList.remove("hidden"); }
function hide(id) { $(id).classList.add("hidden"); }
+ // Template cloning helper
+ function cloneTemplate(id) {
+ return document.getElementById(id).content.cloneNode(true);
+ }
+
const riskColors = {
low: { bg: "bg-green-100 dark:bg-green-900", text: "text-green-700 dark:text-green-300", border: "border-green-500" },
comfort: { bg: "bg-teal-100 dark:bg-teal-900", text: "text-teal-700 dark:text-teal-300", border: "border-teal-500" },
@@ -55,15 +60,6 @@
high: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
};
- const categoryIcons = {
- shading: '',
- ventilation: '',
- internal_gains: '',
- ac_strategy: '',
- hydration: '',
- care: '',
- };
-
function tempColorHex(temp) {
if (temp >= 40) return "#dc2626";
if (temp >= 35) return "#f97316";
@@ -124,7 +120,8 @@
hide("loading");
show("data-display");
- renderDashboard(data);
+ await renderDashboard(data);
+ await initProfileSwitcher(profileId);
initQuickSettings();
// LLM credentials (shared between summary and actions)
@@ -263,7 +260,7 @@
}
// ========== Main Render ==========
- function renderDashboard(data) {
+ async function renderDashboard(data) {
$("profile-name").textContent = data.profileName + " \u2014 " + data.date;
// Detect comfort day: cold weather, low risk
@@ -295,39 +292,49 @@
$("poor-night-cool").classList.remove("hidden");
}
- // Warnings — severity-based coloring
+ // Warnings
if (data.warnings && data.warnings.length > 0) {
show("warnings-section");
- $("warnings-section").innerHTML = '
' + esc(t().warnings) + '
' +
- data.warnings.map(w => {
- const sc = warningSeverityColors[w.severity] || warningSeverityColors.Moderate;
- return `
-
-
- ${esc(w.headline)}
- ${esc(w.severity)}
-
-
${esc(w.description)}
- ${w.instruction ? `
${esc(w.instruction)}
` : ''}
-
${esc(w.onset)} \u2014 ${esc(w.expires)}
-
- `;
- }).join("");
+ const warnList = $("warnings-list");
+ warnList.replaceChildren();
+ for (const w of data.warnings) {
+ const el = cloneTemplate("tpl-warning-card");
+ const sc = warningSeverityColors[w.severity] || warningSeverityColors.Moderate;
+ const card = el.firstElementChild;
+ card.classList.add(...sc.bg.split(" "), ...sc.border.split(" "));
+ const headlineEl = el.querySelector('[data-slot="headline"]');
+ headlineEl.textContent = w.headline;
+ headlineEl.classList.add(...sc.text.split(" "));
+ const sevEl = el.querySelector('[data-slot="severity"]');
+ sevEl.textContent = w.severity;
+ sevEl.classList.add(...sc.pill.split(" "));
+ el.querySelector('[data-slot="description"]').textContent = w.description;
+ if (w.instruction) {
+ const instrEl = el.querySelector('[data-slot="instruction"]');
+ instrEl.textContent = w.instruction;
+ instrEl.classList.remove("hidden");
+ }
+ el.querySelector('[data-slot="onset-expires"]').textContent = `${w.onset} \u2014 ${w.expires}`;
+ warnList.appendChild(el);
+ }
}
// Risk windows
if (data.riskWindows && data.riskWindows.length > 0) {
show("risk-windows-section");
- $("risk-windows").innerHTML = data.riskWindows.map(w => {
+ const rwContainer = $("risk-windows");
+ rwContainer.replaceChildren();
+ for (const w of data.riskWindows) {
+ const el = cloneTemplate("tpl-risk-window");
const wc = riskColors[w.level] || riskColors.low;
- return `
-
- ${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00
- ${w.level}
- ${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${esc(w.reason)}
-
- `;
- }).join("");
+ el.firstElementChild.classList.add(...wc.bg.split(" "));
+ el.querySelector('[data-slot="time-range"]').textContent = `${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00`;
+ const levelEl = el.querySelector('[data-slot="level"]');
+ levelEl.textContent = w.level;
+ levelEl.classList.add(...wc.text.split(" "));
+ el.querySelector('[data-slot="details"]').textContent = `${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${w.reason}`;
+ rwContainer.appendChild(el);
+ }
}
// Timeline heatmap
@@ -338,60 +345,77 @@
// Room budgets
if (data.roomBudgets && data.roomBudgets.length > 0) {
show("budgets-section");
- $("room-budgets").innerHTML = data.roomBudgets.map(rb => {
+ const budgetContainer = $("room-budgets");
+ budgetContainer.replaceChildren();
+ for (const rb of data.roomBudgets) {
+ const el = cloneTemplate("tpl-room-budget");
const maxVal = Math.max(rb.totalGainBtuh, rb.acCapacityBtuh, 1);
const internalPct = (rb.internalGainsW / maxVal) * 100;
const solarPct = (rb.solarGainW / maxVal) * 100;
const ventPct = (rb.ventGainW / maxVal) * 100;
const capPct = Math.min((rb.acCapacityBtuh / maxVal) * 100, 100);
const bc = budgetBorderColors[rb.status] || budgetBorderColors.comfortable;
- return `
-
-
${esc(rb.roomName)}
-
-
${esc(t().internalGains)}${rb.internalGainsW.toFixed(0)} W
-
${esc(t().solarGain)}${rb.solarGainW.toFixed(0)} W
-
${esc(t().ventGain)}${rb.ventGainW.toFixed(0)} W
-
${esc(t().totalGain)}${rb.totalGainBtuh.toFixed(0)} BTU/h
-
${esc(t().acCapacity)}${rb.acCapacityBtuh.toFixed(0)} BTU/h
-
${esc(t().headroom)}${rb.headroomBtuh.toFixed(0)} BTU/h
-
-
-
-
-
- ${esc(t().internalGains)}
- ${esc(t().solarGain)}
- ${esc(t().ventGain)}
- ${esc(t().acCapacity)}
-
-
-
- `;
- }).join("");
+
+ el.firstElementChild.classList.add(bc);
+ el.querySelector('[data-slot="name"]').textContent = rb.roomName;
+ el.querySelector('[data-slot="internal-gains"]').textContent = `${rb.internalGainsW.toFixed(0)} W`;
+ el.querySelector('[data-slot="solar-gain"]').textContent = `${rb.solarGainW.toFixed(0)} W`;
+ el.querySelector('[data-slot="vent-gain"]').textContent = `${rb.ventGainW.toFixed(0)} W`;
+ el.querySelector('[data-slot="total-gain"]').textContent = `${rb.totalGainBtuh.toFixed(0)} BTU/h`;
+ el.querySelector('[data-slot="ac-capacity"]').textContent = `${rb.acCapacityBtuh.toFixed(0)} BTU/h`;
+ el.querySelector('[data-slot="headroom-value"]').textContent = `${rb.headroomBtuh.toFixed(0)} BTU/h`;
+
+ if (rb.headroomBtuh >= 0) {
+ const badEl = el.querySelector('[data-slot="headroom-bad"]');
+ if (badEl) badEl.remove();
+ } else {
+ const okEl = el.querySelector('[data-slot="headroom-ok"]');
+ if (okEl) okEl.remove();
+ const badEl = el.querySelector('[data-slot="headroom-bad"]');
+ badEl.textContent = `${badEl.dataset.label} ${Math.abs(rb.headroomBtuh).toFixed(0)} BTU/h`;
+ badEl.classList.remove("hidden");
+ }
+
+ el.querySelector('[data-slot="bar-internal"]').style.width = `${internalPct.toFixed(1)}%`;
+ el.querySelector('[data-slot="bar-solar"]').style.width = `${solarPct.toFixed(1)}%`;
+ el.querySelector('[data-slot="bar-vent"]').style.width = `${ventPct.toFixed(1)}%`;
+ el.querySelector('[data-slot="bar-ac"]').style.width = `${capPct.toFixed(1)}%`;
+
+ budgetContainer.appendChild(el);
+ }
}
- // Care checklist
+ // Care checklist with persistence
if (data.careChecklist && data.careChecklist.length > 0) {
show("care-section");
- $("care-checklist").innerHTML = data.careChecklist.map(item => `
-
-
- ${esc(item)}
-
- `).join("");
+ const careKey = "care_" + data.date;
+ const savedState = (await getSetting(careKey)) || {};
+ const careList = $("care-checklist");
+ careList.replaceChildren();
+ data.careChecklist.forEach((item, i) => {
+ const el = cloneTemplate("tpl-care-item");
+ const cb = el.querySelector(".care-check");
+ cb.dataset.careIdx = i;
+ cb.dataset.careKey = careKey;
+ if (savedState[i]) cb.checked = true;
+ el.querySelector('[data-slot="text"]').textContent = item;
+ careList.appendChild(el);
+ });
}
// Fade in LLM skeleton
requestAnimationFrame(() => $("llm-summary").classList.remove("opacity-0"));
}
+ // Care checklist event delegation
+ $("care-checklist").addEventListener("change", async (e) => {
+ const cb = e.target.closest(".care-check");
+ if (!cb) return;
+ const state = (await getSetting(cb.dataset.careKey)) || {};
+ state[cb.dataset.careIdx] = cb.checked;
+ await setSetting(cb.dataset.careKey, state);
+ });
+
function formatHourRange(hours) {
if (hours.length === 0) return "";
if (hours.length === 1) return String(hours[0]).padStart(2, '0') + ":00";
@@ -430,39 +454,50 @@
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
});
- const html = sortedCats.map(cat => {
- const icon = categoryIcons[cat] || '';
- const label = (t().category && t().category[cat]) || cat;
- const cards = groups[cat].map(a => {
- const hours = (a.hours || []).sort((x, y) => x - y);
- const hourRange = formatHourRange(hours);
- const ec = effortColors[a.effort] || effortColors.none;
- const ic = impactColors[a.impact] || impactColors.low;
- return `
-
-
-
${esc(a.name)}
-
${hourRange}
-
- ${a.description ? `
${esc(a.description)}
` : ''}
-
- ${esc(t().effort)}: ${esc(a.effort || 'none')}
- ${esc(t().impact)}: ${esc(a.impact || 'low')}
-
-
- `;
- }).join("");
+ const list = $("actions-list");
+ list.replaceChildren();
- return `
-
-
- ${icon}
- ${esc(label)}
-
-
${cards}
-
- `;
- }).join("");
+ for (const cat of sortedCats) {
+ const groupEl = cloneTemplate("tpl-action-group");
+
+ // Clone icon from hidden container
+ const iconSrc = document.getElementById(`icon-${cat}`);
+ if (iconSrc) {
+ groupEl.querySelector('[data-slot="icon"]').appendChild(iconSrc.cloneNode(true));
+ }
+
+ const label = (t().category && t().category[cat]) || cat;
+ groupEl.querySelector('[data-slot="label"]').textContent = label;
+
+ const cardsContainer = groupEl.querySelector('[data-slot="cards"]');
+ for (const a of groups[cat]) {
+ const cardEl = cloneTemplate("tpl-action-card");
+ cardEl.querySelector('[data-slot="name"]').textContent = a.name;
+
+ const hours = (a.hours || []).sort((x, y) => x - y);
+ cardEl.querySelector('[data-slot="hours"]').textContent = formatHourRange(hours);
+
+ if (a.description) {
+ const descEl = cardEl.querySelector('[data-slot="description"]');
+ descEl.textContent = a.description;
+ descEl.classList.remove("hidden");
+ }
+
+ const effortEl = cardEl.querySelector('[data-slot="effort"]');
+ const ec = effortColors[a.effort] || effortColors.none;
+ effortEl.textContent = `${effortEl.dataset.label}: ${a.effort || 'none'}`;
+ effortEl.classList.add(...ec.split(" "));
+
+ const impactEl = cardEl.querySelector('[data-slot="impact"]');
+ const ic = impactColors[a.impact] || impactColors.low;
+ impactEl.textContent = `${impactEl.dataset.label}: ${a.impact || 'low'}`;
+ impactEl.classList.add(...ic.split(" "));
+
+ cardsContainer.appendChild(cardEl);
+ }
+
+ list.appendChild(groupEl);
+ }
hide("actions-loading");
show("actions-section");
@@ -471,7 +506,6 @@
badge.textContent = t().aiActions || "AI";
badge.classList.remove("hidden");
}
- $("actions-list").innerHTML = html;
}
// ========== Heatmap Timeline ==========
@@ -524,7 +558,7 @@
// Hour labels (every 3h) + current hour marker
const hourLabelsHtml = timeline.map(s => {
const isCurrent = s.hour === currentHour;
- if (isCurrent) return `▼
`;
+ if (isCurrent) return `\u25bc
`;
if (s.hour % 3 !== 0) return ``;
return `${String(s.hour).padStart(2, "0")}
`;
}).join("");
@@ -741,6 +775,43 @@
const refreshBtn = $("refresh-forecast-btn");
if (refreshBtn) refreshBtn.addEventListener("click", refreshForecast);
+ // ========== Profile Switcher ==========
+ let _profileSwitcherInit = false;
+ async function initProfileSwitcher(activeId) {
+ const profiles = await dbGetAll("profiles");
+ const switcher = $("profile-switcher");
+ const nameEl = $("profile-name");
+ if (profiles.length <= 1) {
+ switcher.classList.add("hidden");
+ return;
+ }
+ switcher.replaceChildren();
+ for (const p of profiles) {
+ const opt = cloneTemplate("tpl-profile-option").firstElementChild;
+ opt.value = p.id;
+ opt.textContent = p.name;
+ if (p.id === activeId) opt.selected = true;
+ switcher.appendChild(opt);
+ }
+ switcher.classList.remove("hidden");
+ nameEl.classList.add("hidden");
+
+ if (!_profileSwitcherInit) {
+ _profileSwitcherInit = true;
+ switcher.addEventListener("change", async () => {
+ const newId = parseInt(switcher.value);
+ await setActiveProfileId(newId);
+ _qsInitialized = false;
+ _hourActionMap = null;
+ _currentTimeline = null;
+ _currentTimezone = null;
+ _currentDashDate = null;
+ _profileSwitcherInit = false;
+ await loadDashboard();
+ });
+ }
+ }
+
// ========== Quick Settings ==========
let _qsInitialized = false;
async function initQuickSettings() {
@@ -771,6 +842,11 @@
} catch (_) { /* ignore */ }
$("qs-apply").addEventListener("click", async () => {
+ const btn = $("qs-apply");
+ const origText = btn.textContent;
+ btn.disabled = true;
+ btn.innerHTML = '\u21bb' + esc(origText);
+
const tempVal = parseFloat($("qs-indoor-temp").value);
const humVal = parseFloat($("qs-indoor-humidity").value);
try {
@@ -791,9 +867,12 @@
_currentTimeline = null;
_currentTimezone = null;
_currentDashDate = null;
- loadDashboard();
+ await loadDashboard();
} catch (e) {
console.error("Quick settings apply error:", e);
+ } finally {
+ btn.disabled = false;
+ btn.textContent = origText;
}
});
}
diff --git a/web/js/setup.js b/web/js/setup.js
index 4612411..8932003 100644
--- a/web/js/setup.js
+++ b/web/js/setup.js
@@ -2,10 +2,6 @@
(function() {
"use strict";
- // SVG icon templates
- const iconEdit = '';
- const iconDelete = '';
-
// Tab switching
const tabBtns = document.querySelectorAll(".tab-btn");
const tabPanels = document.querySelectorAll(".tab-panel");
@@ -13,6 +9,12 @@
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");
@@ -31,31 +33,43 @@
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);
+ // Template cloning helper
+ function cloneTemplate(id) {
+ return document.getElementById(id).content.cloneNode(true);
}
- // Tooltip handling
- document.addEventListener("click", (e) => {
+ // 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) {
- document.querySelectorAll(".tooltip-popup").forEach(p => p.remove());
- return;
- }
+ 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);
- setTimeout(() => tip.remove(), 5000);
- });
+ }, 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) {
@@ -104,22 +118,6 @@
});
});
- // 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");
@@ -129,32 +127,32 @@
return;
}
const activeId = await getActiveProfileId();
- list.innerHTML = profiles.map(p => {
+ list.replaceChildren();
+ for (const p of profiles) {
+ const el = cloneTemplate("tpl-profile-card");
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("");
+ 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);
+ }
}
- window.activateProfile = async function(id) {
+ async function activateProfile(id) {
await setActiveProfileId(id);
await loadProfiles();
await refreshRoomSelects();
showToast("Profile activated", false);
- };
+ }
- window.editProfileUI = async function(id) {
+ async function editProfileUI(id) {
const p = await dbGet("profiles", id);
if (!p) return;
const form = document.getElementById("profile-form");
@@ -165,16 +163,29 @@
form.querySelector('input[name="timezone"]').value = p.timezone || "Europe/Berlin";
enterEditMode(form);
form.querySelector('input[name="name"]').focus();
- };
+ }
- window.deleteProfileUI = async function(id) {
+ 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();
@@ -196,6 +207,7 @@
}
resetForm(e.target);
await loadProfiles();
+ await updateTabBadges();
showToast("Profile saved", false);
});
@@ -203,7 +215,7 @@
document.getElementById("geolocate-btn").addEventListener("click", () => {
const btn = document.getElementById("geolocate-btn");
btn.disabled = true;
- btn.textContent = "⟳ Detecting…";
+ btn.textContent = "\u27f3 Detecting\u2026";
navigator.geolocation.getCurrentPosition(
(pos) => {
document.querySelector('#profile-form input[name="latitude"]').value = pos.coords.latitude.toFixed(4);
@@ -211,13 +223,13 @@
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";
+ 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 = "📍 Use my location";
+ btn.textContent = "\ud83d\udccd Use my location";
},
{ timeout: 10000 }
);
@@ -233,18 +245,25 @@
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("");
+ // 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);
+ }
}
- window.editRoomUI = async function(id) {
+ async function editRoomUI(id) {
const r = await dbGet("rooms", id);
if (!r) return;
const form = document.getElementById("room-form");
@@ -263,15 +282,28 @@
form.querySelector('input[name="indoorTempC"]').value = r.indoorTempC || 25;
enterEditMode(form);
form.querySelector('input[name="name"]').focus();
- };
+ await loadWindowsForRoom(r.id);
+ }
- window.deleteRoomUI = async function(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();
@@ -287,25 +319,121 @@
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),
};
+ let roomId;
if (data.id) {
room.id = parseInt(data.id);
await dbPut("rooms", room);
+ roomId = room.id;
} else {
- await dbAdd("rooms", room);
+ 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();
@@ -321,18 +449,17 @@
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("");
+ 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);
+ }
}
- window.editDeviceUI = async function(id) {
+ async function editDeviceUI(id) {
const d = await dbGet("devices", id);
if (!d) return;
const form = document.getElementById("device-form");
@@ -346,13 +473,25 @@
form.querySelector('input[name="dutyCycle"]').value = d.dutyCycle ?? 1.0;
enterEditMode(form);
form.querySelector('input[name="name"]').focus();
- };
+ }
- window.deleteDeviceUI = async function(id) {
+ 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();
@@ -374,6 +513,7 @@
}
resetForm(e.target);
await loadDevices();
+ await updateTabBadges();
showToast("Device saved", false);
});
@@ -392,18 +532,17 @@
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("");
+ 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);
+ }
}
- window.editOccupantUI = async function(id) {
+ async function editOccupantUI(id) {
const o = await dbGet("occupants", id);
if (!o) return;
const form = document.getElementById("occupant-form");
@@ -414,13 +553,25 @@
form.querySelector('input[name="vulnerable"]').checked = !!o.vulnerable;
enterEditMode(form);
form.querySelector('input[name="count"]').focus();
- };
+ }
- window.deleteOccupantUI = async function(id) {
+ 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();
@@ -439,6 +590,7 @@
}
resetForm(e.target);
await loadOccupants();
+ await updateTabBadges();
showToast("Occupant saved", false);
});
@@ -456,22 +608,19 @@
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
const roomMap = Object.fromEntries(rooms.map(r => [r.id, r.name]));
- list.innerHTML = units.map(u => {
+ 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(", ");
- return `
-
-
- ${esc(u.name)}
- ${u.capacityBtu} BTU · ${esc(u.acType)}${roomNames ? ' · ' + esc(roomNames) : ''}
-
- ${actionGroup(`editACUI(${u.id})`, `deleteACUI(${u.id})`)}
-
- `;
- }).join("");
+ el.querySelector('[data-slot="name"]').textContent = u.name;
+ el.querySelector('[data-slot="details"]').textContent = `${u.capacityBtu} BTU \u00b7 ${u.acType}${roomNames ? ' \u00b7 ' + roomNames : ''}`;
+ el.firstElementChild.dataset.id = u.id;
+ list.appendChild(el);
+ }
}
- window.editACUI = async function(id) {
+ async function editACUI(id) {
const u = await dbGet("ac_units", id);
if (!u) return;
const form = document.getElementById("ac-form");
@@ -489,17 +638,29 @@
});
enterEditMode(form);
form.querySelector('input[name="name"]').focus();
- };
+ }
- window.deleteACUI = async function(id) {
+ 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();
@@ -533,6 +694,7 @@
}
resetForm(e.target);
await loadACUnits();
+ await updateTabBadges();
showToast("AC unit saved", false);
});
@@ -634,29 +796,93 @@
const profileId = await getActiveProfileId();
if (!profileId) return;
const rooms = await dbGetByIndex("rooms", "profileId", profileId);
- const options = rooms.map(r => ``).join("");
+ const hasRooms = rooms.length > 0;
["device-room-select", "occupant-room-select"].forEach(id => {
const el = document.getElementById(id);
- if (el) el.innerHTML = options;
+ 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.innerHTML = rooms.map(r => `
-
- `).join("");
+ 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);
+ }
}
}
- // ========== Utility ==========
- function esc(s) {
- const div = document.createElement("div");
- div.textContent = s;
- return div.innerHTML;
+ // ========== 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 ==========
@@ -670,6 +896,7 @@
await loadForecastStatus();
await loadLLMConfig();
await refreshRoomSelects();
+ await updateTabBadges();
}
init();
diff --git a/web/templates/dashboard.html b/web/templates/dashboard.html
index 08974e2..00bac58 100644
--- a/web/templates/dashboard.html
+++ b/web/templates/dashboard.html
@@ -48,6 +48,7 @@
{{t "dashboard.refreshForecast"}}
+
@@ -74,7 +75,10 @@
-
+
+
{{t "dashboard.warnings"}}
+
+
@@ -161,35 +165,118 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{t "dashboard.internalGains"}}
+
{{t "dashboard.solarGain"}}
+
{{t "dashboard.ventGain"}}
+
{{t "dashboard.totalGain"}}
+
{{t "dashboard.acCapacity"}}
+
{{t "dashboard.headroom"}}
+
{{t "dashboard.headroomOk"}}
+
+
+
+
+
+
+ {{t "dashboard.internalGains"}}
+ {{t "dashboard.solarGain"}}
+ {{t "dashboard.ventGain"}}
+ {{t "dashboard.acCapacity"}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{end}}
{{define "scripts"}}