refactor: replace innerHTML with HTML template cloning and event delegation

Move dynamic HTML generation from JS template literals to native
<template> elements rendered server-side with i18n. JS now clones
templates, fills data-slot attributes via textContent (auto-escaped),
and uses event delegation instead of inline onclick/window globals.
This commit is contained in:
2026-02-10 23:17:31 +01:00
parent 0bc73dbef7
commit 94798631bc
4 changed files with 795 additions and 254 deletions

View File

@@ -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: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18"/></svg>',
ventilation: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.59 4.59A2 2 0 1111 8H2m10.59 11.41A2 2 0 1014 16H2m15.73-8.27A2.5 2.5 0 1119.5 12H2"/></svg>',
internal_gains: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>',
ac_strategy: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>',
hydration: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2.69l5.66 5.66a8 8 0 11-11.31 0z"/></svg>',
care: '<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>',
};
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 = '<h2 class="text-lg font-semibold mb-2">' + esc(t().warnings) + '</h2>' +
data.warnings.map(w => {
const sc = warningSeverityColors[w.severity] || warningSeverityColors.Moderate;
return `
<div class="${sc.bg} border ${sc.border} rounded-lg p-3 transition-all duration-200 hover:shadow-md">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium ${sc.text}">${esc(w.headline)}</span>
<span class="text-xs px-2 py-0.5 rounded-full ${sc.pill}">${esc(w.severity)}</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">${esc(w.description)}</div>
${w.instruction ? `<div class="text-sm text-orange-700 dark:text-orange-300 mt-1">${esc(w.instruction)}</div>` : ''}
<div class="text-xs text-gray-400 mt-1">${esc(w.onset)} \u2014 ${esc(w.expires)}</div>
</div>
`;
}).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 `
<div class="flex items-center gap-3 ${wc.bg} rounded-lg px-3 py-2 transition-all duration-200 hover:shadow-md">
<span class="font-mono text-sm">${String(w.startHour).padStart(2,'0')}:00\u2013${String(w.endHour).padStart(2,'0')}:00</span>
<span class="capitalize font-medium ${wc.text}">${w.level}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">${w.peakTempC.toFixed(1)}\u00b0C \u2014 ${esc(w.reason)}</span>
</div>
`;
}).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 `
<div class="bg-white dark:bg-gray-800 rounded-xl p-3 shadow-sm border-l-4 ${bc}">
<div class="font-medium text-sm mb-2">${esc(rb.roomName)}</div>
<div class="space-y-0.5 text-xs">
<div class="flex justify-between"><span>${esc(t().internalGains)}</span><span>${rb.internalGainsW.toFixed(0)} W</span></div>
<div class="flex justify-between"><span>${esc(t().solarGain)}</span><span>${rb.solarGainW.toFixed(0)} W</span></div>
<div class="flex justify-between"><span>${esc(t().ventGain)}</span><span>${rb.ventGainW.toFixed(0)} W</span></div>
<div class="flex justify-between font-medium"><span>${esc(t().totalGain)}</span><span>${rb.totalGainBtuh.toFixed(0)} BTU/h</span></div>
<div class="flex justify-between"><span>${esc(t().acCapacity)}</span><span>${rb.acCapacityBtuh.toFixed(0)} BTU/h</span></div>
<div class="flex justify-between font-medium"><span>${esc(t().headroom)}</span><span>${rb.headroomBtuh.toFixed(0)} BTU/h</span></div>
</div>
<div class="mt-2 space-y-1">
<div class="h-2.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden flex">
<div class="h-full bg-amber-400" style="width: ${internalPct.toFixed(1)}%" title="${esc(t().internalGains)}"></div>
<div class="h-full bg-orange-400" style="width: ${solarPct.toFixed(1)}%" title="${esc(t().solarGain)}"></div>
<div class="h-full bg-rose-400" style="width: ${ventPct.toFixed(1)}%" title="${esc(t().ventGain)}"></div>
</div>
<div class="h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div class="h-full rounded-full bg-blue-400" style="width: ${capPct.toFixed(1)}%"></div>
</div>
<div class="flex gap-2 text-xs text-gray-400 flex-wrap">
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-amber-400"></span>${esc(t().internalGains)}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-orange-400"></span>${esc(t().solarGain)}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-rose-400"></span>${esc(t().ventGain)}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-blue-400"></span>${esc(t().acCapacity)}</span>
</div>
</div>
</div>
`;
}).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 => `
<li class="flex items-start gap-2">
<input type="checkbox" class="mt-1 rounded">
<span class="text-sm">${esc(item)}</span>
</li>
`).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 `
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm transition-all duration-200 hover:shadow-md">
<div class="flex items-start justify-between gap-2">
<div class="font-medium text-sm">${esc(a.name)}</div>
<div class="text-xs text-gray-400 whitespace-nowrap">${hourRange}</div>
</div>
${a.description ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${esc(a.description)}</div>` : ''}
<div class="flex gap-2 mt-2">
<span class="text-xs px-2 py-0.5 rounded-full ${ec}">${esc(t().effort)}: ${esc(a.effort || 'none')}</span>
<span class="text-xs px-2 py-0.5 rounded-full ${ic}">${esc(t().impact)}: ${esc(a.impact || 'low')}</span>
</div>
</div>
`;
}).join("");
const list = $("actions-list");
list.replaceChildren();
return `
<div>
<div class="flex items-center gap-2 mb-2 text-gray-600 dark:text-gray-300">
${icon}
<span class="font-medium text-sm">${esc(label)}</span>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-2">${cards}</div>
</div>
`;
}).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 `<div class="text-center text-xs font-bold text-orange-500"></div>`;
if (isCurrent) return `<div class="text-center text-xs font-bold text-orange-500">\u25bc</div>`;
if (s.hour % 3 !== 0) return `<div></div>`;
return `<div class="text-center text-xs text-gray-400">${String(s.hour).padStart(2, "0")}</div>`;
}).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 = '<span class="inline-block animate-spin mr-1">\u21bb</span>' + 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;
}
});
}

View File

@@ -2,10 +2,6 @@
(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");
@@ -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 `<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");
@@ -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 `
<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("");
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 = '<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("");
// 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 = '<p class="text-sm text-gray-400 dark:text-gray-500">No windows. Room-level solar defaults are used.</p>';
return;
}
list.replaceChildren();
for (const w of windows) {
const el = cloneTemplate("tpl-window-card");
el.querySelector('[data-slot="orientation"]').textContent = w.orientation;
el.querySelector('[data-slot="details"]').textContent = `${w.areaSqm}m\u00b2 \u00b7 SHGC ${w.shgc} \u00b7 ${w.shadingType} (${w.shadingFactor})`;
el.firstElementChild.dataset.id = w.id;
list.appendChild(el);
}
}
async function editWindowUI(id) {
const w = await dbGet("windows", id);
if (!w) return;
const form = document.getElementById("window-form");
form.querySelector('input[name="id"]').value = w.id;
form.querySelector('input[name="roomId"]').value = w.roomId;
form.querySelector('select[name="orientation"]').value = w.orientation || "S";
form.querySelector('input[name="areaSqm"]').value = w.areaSqm || "";
form.querySelector('input[name="shgc"]').value = w.shgc || 0.6;
form.querySelector('select[name="shadingType"]').value = w.shadingType || "none";
form.querySelector('input[name="shadingFactor"]').value = w.shadingFactor ?? 1.0;
enterEditMode(form);
}
async function deleteWindowUI(id) {
await dbDelete("windows", id);
if (_currentWindowRoomId) await loadWindowsForRoom(_currentWindowRoomId);
await loadRooms();
showToast("Window deleted", false);
}
// Event delegation for windows list
document.getElementById("windows-list").addEventListener("click", async (e) => {
const btn = e.target.closest("[data-action]");
if (!btn) return;
const card = btn.closest("[data-id]");
if (!card) return;
const id = parseInt(card.dataset.id);
if (btn.dataset.action === "edit") await editWindowUI(id);
else if (btn.dataset.action === "delete") await deleteWindowUI(id);
});
document.getElementById("window-form").addEventListener("submit", async (e) => {
e.preventDefault();
const data = formData(e.target);
const roomId = parseInt(data.roomId);
if (!roomId) { showToast("No room selected", true); return; }
const win = {
roomId,
orientation: data.orientation || "S",
areaSqm: numOrDefault(data.areaSqm, 1.0),
shgc: numOrDefault(data.shgc, 0.6),
shadingType: data.shadingType || "none",
shadingFactor: numOrDefault(data.shadingFactor, 1.0),
};
if (data.id) {
win.id = parseInt(data.id);
await dbPut("windows", win);
} else {
await dbAdd("windows", win);
}
resetForm(e.target);
e.target.querySelector('input[name="roomId"]').value = roomId;
await loadWindowsForRoom(roomId);
await loadRooms();
showToast("Window saved", false);
});
// ========== Devices ==========
async function loadDevices() {
const profileId = await getActiveProfileId();
@@ -321,18 +449,17 @@
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("");
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 = '<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("");
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 `
<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("");
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 => `<option value="${r.id}">${esc(r.name)}</option>`).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 => `
<label class="flex items-center gap-1 text-sm">
<input type="checkbox" value="${r.id}" class="rounded"> ${esc(r.name)}
</label>
`).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();

View File

@@ -48,6 +48,7 @@
<svg id="refresh-icon" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg>
<span class="hidden sm:inline">{{t "dashboard.refreshForecast"}}</span>
</button>
<select id="profile-switcher" class="hidden text-sm text-gray-500 dark:text-gray-400 bg-transparent border border-gray-300 dark:border-gray-600 rounded px-2 py-1"></select>
<span id="profile-name" class="text-sm text-gray-500 dark:text-gray-400"></span>
</div>
</div>
@@ -74,7 +75,10 @@
</div>
<!-- Warnings -->
<div id="warnings-section" class="hidden space-y-2"></div>
<div id="warnings-section" class="hidden space-y-2">
<h2 class="text-lg font-semibold mb-2">{{t "dashboard.warnings"}}</h2>
<div id="warnings-list" class="space-y-2"></div>
</div>
<!-- Two-column: Main content + Sidebar -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
@@ -161,35 +165,118 @@
</div>
</div>
</div>
<!-- Hidden category icons (cloned by dashboard.js) -->
<div id="category-icons" class="hidden">
<svg id="icon-shading" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18"/></svg>
<svg id="icon-ventilation" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9.59 4.59A2 2 0 1111 8H2m10.59 11.41A2 2 0 1014 16H2m15.73-8.27A2.5 2.5 0 1119.5 12H2"/></svg>
<svg id="icon-internal_gains" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
<svg id="icon-ac_strategy" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83"/><circle cx="12" cy="12" r="3"/></svg>
<svg id="icon-hydration" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2.69l5.66 5.66a8 8 0 11-11.31 0z"/></svg>
<svg id="icon-care" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
</div>
<!-- Dashboard templates (cloned by dashboard.js) -->
<template id="tpl-warning-card">
<div class="border rounded-lg p-3 transition-all duration-200 hover:shadow-md">
<div class="flex items-center gap-2 mb-1">
<span class="font-medium" data-slot="headline"></span>
<span class="text-xs px-2 py-0.5 rounded-full" data-slot="severity"></span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400" data-slot="description"></div>
<div class="text-sm text-orange-700 dark:text-orange-300 mt-1 hidden" data-slot="instruction"></div>
<div class="text-xs text-gray-400 mt-1" data-slot="onset-expires"></div>
</div>
</template>
<template id="tpl-risk-window">
<div class="flex items-center gap-3 rounded-lg px-3 py-2 transition-all duration-200 hover:shadow-md">
<span class="font-mono text-sm" data-slot="time-range"></span>
<span class="capitalize font-medium" data-slot="level"></span>
<span class="text-sm text-gray-500 dark:text-gray-400" data-slot="details"></span>
</div>
</template>
<template id="tpl-room-budget">
<div class="bg-white dark:bg-gray-800 rounded-xl p-3 shadow-sm border-l-4">
<div class="font-medium text-sm mb-2" data-slot="name"></div>
<div class="space-y-0.5 text-xs">
<div class="flex justify-between"><span>{{t "dashboard.internalGains"}}</span><span data-slot="internal-gains"></span></div>
<div class="flex justify-between"><span>{{t "dashboard.solarGain"}}</span><span data-slot="solar-gain"></span></div>
<div class="flex justify-between"><span>{{t "dashboard.ventGain"}}</span><span data-slot="vent-gain"></span></div>
<div class="flex justify-between font-medium"><span>{{t "dashboard.totalGain"}}</span><span data-slot="total-gain"></span></div>
<div class="flex justify-between"><span>{{t "dashboard.acCapacity"}}</span><span data-slot="ac-capacity"></span></div>
<div class="flex justify-between font-medium"><span>{{t "dashboard.headroom"}}</span><span data-slot="headroom-value"></span></div>
<div class="text-xs mt-0.5 text-green-600 dark:text-green-400" data-slot="headroom-ok">{{t "dashboard.headroomOk"}}</div>
<div class="text-xs mt-0.5 text-red-600 dark:text-red-400 hidden" data-slot="headroom-bad" data-label="{{t "dashboard.headroomInsufficient"}}"></div>
</div>
<div class="mt-2 space-y-1">
<div class="h-2.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden flex">
<div class="h-full bg-amber-400" data-slot="bar-internal" title="{{t "dashboard.internalGains"}}"></div>
<div class="h-full bg-orange-400" data-slot="bar-solar" title="{{t "dashboard.solarGain"}}"></div>
<div class="h-full bg-rose-400" data-slot="bar-vent" title="{{t "dashboard.ventGain"}}"></div>
</div>
<div class="h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
<div class="h-full rounded-full bg-blue-400" data-slot="bar-ac"></div>
</div>
<div class="flex gap-2 text-xs text-gray-400 flex-wrap">
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-amber-400"></span>{{t "dashboard.internalGains"}}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-orange-400"></span>{{t "dashboard.solarGain"}}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-rose-400"></span>{{t "dashboard.ventGain"}}</span>
<span class="flex items-center gap-1"><span class="inline-block w-1.5 h-1.5 rounded-full bg-blue-400"></span>{{t "dashboard.acCapacity"}}</span>
</div>
</div>
</div>
</template>
<template id="tpl-care-item">
<li class="flex items-start gap-2">
<input type="checkbox" class="mt-1 rounded care-check">
<span class="text-sm" data-slot="text"></span>
</li>
</template>
<template id="tpl-action-card">
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm transition-all duration-200 hover:shadow-md">
<div class="flex items-start justify-between gap-2">
<div class="font-medium text-sm" data-slot="name"></div>
<div class="text-xs text-gray-400 whitespace-nowrap" data-slot="hours"></div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 hidden" data-slot="description"></div>
<div class="flex gap-2 mt-2">
<span class="text-xs px-2 py-0.5 rounded-full" data-slot="effort" data-label="{{t "dashboard.effort"}}"></span>
<span class="text-xs px-2 py-0.5 rounded-full" data-slot="impact" data-label="{{t "dashboard.impact"}}"></span>
</div>
</div>
</template>
<template id="tpl-action-group">
<div>
<div class="flex items-center gap-2 mb-2 text-gray-600 dark:text-gray-300">
<span data-slot="icon"></span>
<span class="font-medium text-sm" data-slot="label"></span>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-2" data-slot="cards"></div>
</div>
</template>
<template id="tpl-profile-option">
<option></option>
</template>
</div>
{{end}}
{{define "scripts"}}
<script>
window.HG.t = {
warnings: "{{t "dashboard.warnings"}}",
noActions: "{{t "dashboard.noActions"}}",
effort: "{{t "dashboard.effort"}}",
impact: "{{t "dashboard.impact"}}",
aiDisclaimer: "{{t "dashboard.aiDisclaimer"}}",
actions: "{{t "dashboard.actions"}}",
internalGains: "{{t "dashboard.internalGains"}}",
solarGain: "{{t "dashboard.solarGain"}}",
ventGain: "{{t "dashboard.ventGain"}}",
totalGain: "{{t "dashboard.totalGain"}}",
acCapacity: "{{t "dashboard.acCapacity"}}",
headroom: "{{t "dashboard.headroom"}}",
riskComfort: "{{t "dashboard.riskComfort"}}",
coolComfort: "{{t "dashboard.coolComfort"}}",
coolVentilate: "{{t "dashboard.coolVentilate"}}",
coolAC: "{{t "dashboard.coolAC"}}",
coolOverloaded: "{{t "dashboard.coolOverloaded"}}",
coolSealed: "{{t "dashboard.coolSealed"}}",
aiDisclaimer: "{{t "dashboard.aiDisclaimer"}}",
aiActions: "{{t "dashboard.aiActions"}}",
quickSettings: "{{t "dashboard.quickSettings"}}",
qsIndoorTemp: "{{t "dashboard.qsIndoorTemp"}}",
qsIndoorHumidity: "{{t "dashboard.qsIndoorHumidity"}}",
qsApply: "{{t "dashboard.qsApply"}}",
legendTemp: "{{t "dashboard.legendTemp"}}",
legendCooling: "{{t "dashboard.legendCooling"}}",
legendAI: "{{t "dashboard.legendAI"}}",

View File

@@ -127,11 +127,59 @@
<div id="rooms-list" class="space-y-2">
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.rooms.noItems"}}</p>
</div>
<!-- Windows sub-section (shown after a room is saved/selected for editing) -->
<div id="windows-section" class="mt-6 hidden">
<h3 class="text-md font-semibold mb-2">{{t "setup.windows.title"}}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">{{t "setup.windows.help"}}</p>
<div id="windows-save-first" class="hidden text-sm text-gray-400 italic mb-3">{{t "setup.windows.saveRoomFirst"}}</div>
<form id="window-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div>
<label class="block text-xs font-medium mb-1">{{t "setup.windows.orientation.label"}}</label>
<select name="orientation" class="w-full px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
<option value="N">{{t "setup.rooms.orientation.options.N"}}</option><option value="NE">{{t "setup.rooms.orientation.options.NE"}}</option><option value="E">{{t "setup.rooms.orientation.options.E"}}</option>
<option value="SE">{{t "setup.rooms.orientation.options.SE"}}</option><option value="S" selected>{{t "setup.rooms.orientation.options.S"}}</option><option value="SW">{{t "setup.rooms.orientation.options.SW"}}</option>
<option value="W">{{t "setup.rooms.orientation.options.W"}}</option><option value="NW">{{t "setup.rooms.orientation.options.NW"}}</option>
</select>
</div>
<div>
<label class="block text-xs font-medium mb-1">{{t "setup.windows.area.label"}}</label>
<input type="number" name="areaSqm" step="0.1" min="0.1" required class="w-full px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
</div>
<div>
<label class="block text-xs font-medium mb-1">{{t "setup.windows.shgc.label"}}</label>
<input type="number" name="shgc" step="0.1" min="0" max="1" value="0.6" class="w-full px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
</div>
<div>
<label class="block text-xs font-medium mb-1">{{t "setup.windows.shadingType.label"}}</label>
<select name="shadingType" class="w-full px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
<option value="none">{{t "setup.rooms.shadingType.options.none"}}</option><option value="blinds">{{t "setup.rooms.shadingType.options.blinds"}}</option>
<option value="shutters">{{t "setup.rooms.shadingType.options.shutters"}}</option><option value="awning">{{t "setup.rooms.shadingType.options.awning"}}</option>
</select>
</div>
<div>
<label class="block text-xs font-medium mb-1">{{t "setup.windows.shadingFactor.label"}}</label>
<input type="number" name="shadingFactor" step="0.1" min="0" max="1" value="1.0" class="w-full px-2 py-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-sm">
</div>
</div>
<div class="flex gap-2">
<button type="submit" class="submit-btn px-3 py-1 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition" data-add-text="{{t "setup.windows.add"}}" data-save-text="{{t "setup.windows.save"}}">{{t "setup.windows.add"}}</button>
<button type="button" class="cancel-btn hidden px-3 py-1 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition">{{t "common.cancel"}}</button>
</div>
<input type="hidden" name="id" value="">
<input type="hidden" name="roomId" value="">
</form>
<div id="windows-list" class="space-y-2">
<p class="text-sm text-gray-400 dark:text-gray-500">{{t "setup.windows.noItems"}}</p>
</div>
</div>
</section>
<!-- Devices tab -->
<section id="tab-devices" class="tab-panel hidden">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.devices.help"}}</p>
<div id="device-no-rooms" class="hidden text-sm text-amber-600 dark:text-amber-400 mb-3">{{t "setup.devices.noRooms"}}</div>
<form id="device-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div>
@@ -177,6 +225,7 @@
<!-- Occupants tab -->
<section id="tab-occupants" class="tab-panel hidden">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.occupants.help"}}</p>
<div id="occupant-no-rooms" class="hidden text-sm text-amber-600 dark:text-amber-400 mb-3">{{t "setup.occupants.noRooms"}}</div>
<form id="occupant-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
<div>
@@ -215,6 +264,7 @@
<!-- AC Units tab -->
<section id="tab-ac" class="tab-panel hidden">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">{{t "setup.ac.help"}}</p>
<div id="ac-no-rooms" class="hidden text-sm text-amber-600 dark:text-amber-400 mb-3">{{t "setup.ac.noRooms"}}</div>
<form id="ac-form" class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-3 mb-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div>
@@ -328,6 +378,104 @@
</form>
</div>
</section>
<!-- Card templates (cloned by setup.js) -->
<template id="tpl-profile-card">
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
<div>
<span class="font-medium" data-slot="name"></span>
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
</div>
<div class="flex items-center gap-2">
<button data-action="activate" class="text-xs px-2 py-1 rounded"></button>
<div class="flex items-center gap-1">
<button data-action="edit" 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"><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></button>
<button data-action="delete" 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"><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></button>
</div>
</div>
</div>
</template>
<template id="tpl-room-card">
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
<div>
<span class="font-medium" data-slot="name"></span>
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
</div>
<div class="flex items-center gap-1">
<button data-action="edit" 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"><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></button>
<button data-action="delete" 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"><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></button>
</div>
</div>
</template>
<template id="tpl-window-card">
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
<div>
<span class="font-medium" data-slot="orientation"></span>
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
</div>
<div class="flex items-center gap-1">
<button data-action="edit" 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"><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></button>
<button data-action="delete" 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"><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></button>
</div>
</div>
</template>
<template id="tpl-device-card">
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
<div>
<span class="font-medium" data-slot="name"></span>
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
</div>
<div class="flex items-center gap-1">
<button data-action="edit" 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"><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></button>
<button data-action="delete" 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"><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></button>
</div>
</div>
</template>
<template id="tpl-occupant-card">
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
<div>
<span class="font-medium" data-slot="count-activity"></span>
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
</div>
<div class="flex items-center gap-1">
<button data-action="edit" 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"><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></button>
<button data-action="delete" 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"><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></button>
</div>
</div>
</template>
<template id="tpl-ac-card">
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 shadow-sm flex items-center justify-between hover:shadow-md transition-all duration-200">
<div>
<span class="font-medium" data-slot="name"></span>
<span class="text-xs text-gray-400 ml-2" data-slot="details"></span>
</div>
<div class="flex items-center gap-1">
<button data-action="edit" 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"><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></button>
<button data-action="delete" 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"><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></button>
</div>
</div>
</template>
<template id="tpl-room-option">
<option></option>
</template>
<template id="tpl-ac-room-checkbox">
<label class="flex items-center gap-1 text-sm">
<input type="checkbox" class="rounded">
<span data-slot="name"></span>
</label>
</template>
<template id="tpl-toast">
<span data-slot="message"></span>
<button class="ml-2 text-white/80 hover:text-white text-lg leading-none" data-action="close">&times;</button>
</template>
</div>
{{end}}