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 @@ + @@ -74,7 +75,10 @@ - +
    @@ -161,35 +165,118 @@
    + + + + + + + + + + + + + + + + + + {{end}} {{define "scripts"}}