// Dashboard page logic (function() { "use strict"; const $ = (id) => document.getElementById(id); const t = () => window.HG.t || {}; let _hourActionMap = null; let _currentTimeline = null; let _currentTimezone = null; let _currentDashDate = null; const BTU_PER_KW = 3412.14; let _capacityUnit = "btuh"; function fmtCap(btuh) { if (_capacityUnit === "kw") return (btuh / BTU_PER_KW).toFixed(2) + " kW"; return btuh.toFixed(0) + " BTU/h"; } 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" }, moderate: { bg: "bg-yellow-100 dark:bg-yellow-900", text: "text-yellow-700 dark:text-yellow-300", border: "border-yellow-500" }, high: { bg: "bg-orange-100 dark:bg-orange-900", text: "text-orange-700 dark:text-orange-300", border: "border-orange-500" }, extreme: { bg: "bg-red-100 dark:bg-red-900", text: "text-red-700 dark:text-red-300", border: "border-red-500" }, }; const riskGradients = { low: { from: "#f0fdf4", to: "#dcfce7", darkFrom: "#052e16", darkTo: "#064e3b" }, comfort: { from: "#f0fdfa", to: "#ccfbf1", darkFrom: "#042f2e", darkTo: "#134e4a" }, moderate: { from: "#fefce8", to: "#fef9c3", darkFrom: "#422006", darkTo: "#3f3f00" }, high: { from: "#fff7ed", to: "#ffedd5", darkFrom: "#431407", darkTo: "#7c2d12" }, extreme: { from: "#fef2f2", to: "#fecaca", darkFrom: "#450a0a", darkTo: "#7f1d1d" }, }; const warningSeverityColors = { Minor: { bg: "bg-yellow-50 dark:bg-yellow-900/30", border: "border-yellow-300 dark:border-yellow-700", text: "text-yellow-700 dark:text-yellow-300", pill: "bg-yellow-200 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-200" }, Moderate: { bg: "bg-orange-50 dark:bg-orange-900/30", border: "border-orange-300 dark:border-orange-700", text: "text-orange-700 dark:text-orange-300", pill: "bg-orange-200 text-orange-800 dark:bg-orange-800 dark:text-orange-200" }, Severe: { bg: "bg-red-50 dark:bg-red-900/30", border: "border-red-300 dark:border-red-800", text: "text-red-700 dark:text-red-300", pill: "bg-red-200 text-red-800 dark:bg-red-800 dark:text-red-200" }, Extreme: { bg: "bg-red-100 dark:bg-red-900/50", border: "border-red-500 dark:border-red-600", text: "text-red-800 dark:text-red-200", pill: "bg-red-300 text-red-900 dark:bg-red-700 dark:text-red-100" }, }; const budgetBorderColors = { comfortable: "border-green-400", marginal: "border-yellow-400", overloaded: "border-red-400", }; const effortColors = { none: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300", low: "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300", medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300", high: "bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300", }; const impactColors = { low: "bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300", medium: "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300", high: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300", }; function tempColorHex(temp) { if (temp >= 40) return "#dc2626"; if (temp >= 35) return "#f97316"; if (temp >= 30) return "#facc15"; if (temp >= 25) return "#fde68a"; if (temp >= 20) return "#bbf7d0"; if (temp >= 10) return "#bfdbfe"; if (temp >= 0) return "#93c5fd"; return "#6366f1"; } function isDark() { return document.documentElement.classList.contains("dark") || window.matchMedia("(prefers-color-scheme: dark)").matches; } const STALE_THRESHOLD_MS = 3 * 60 * 60 * 1000; // 3 hours function displayDetailedError(err) { const ts = t(); const msgEl = $("error-message"); const hintEl = $("error-hint"); const typeMap = { timeout: ts.errorTimeout || "Weather service timed out.", network: ts.errorNetwork || "Could not reach weather service.", upstream: ts.errorUpstream || "Weather service returned an error.", }; const msg = (err && err.type && typeMap[err.type]) || ts.errorUnknown || "Something went wrong."; if (msgEl) msgEl.textContent = msg; if (hintEl) hintEl.textContent = err && err.message && err.message !== msg ? err.message : ""; } window.loadDashboard = async function() { hide("no-data"); hide("no-forecast"); hide("error-state"); hide("stale-warning"); hide("auto-fetch-indicator"); hide("data-display"); show("loading"); try { const profileId = await getActiveProfileId(); if (!profileId) { hide("loading"); show("no-data"); return; } let forecasts = await dbGetByIndex("forecasts", "profileId", profileId); // Auto-fetch if data is stale or missing const lastFetched = await getSetting("lastFetched"); const isStale = !lastFetched || (Date.now() - new Date(lastFetched).getTime()) > STALE_THRESHOLD_MS; if (isStale) { if (forecasts.length > 0) { // Have old data — auto-fetch silently in background show("auto-fetch-indicator"); try { await fetchForecastForProfile(profileId); forecasts = await dbGetByIndex("forecasts", "profileId", profileId); } catch (fetchErr) { // Old data exists — show stale warning, continue with old data show("stale-warning"); } hide("auto-fetch-indicator"); } else { // No data at all — try to fetch show("auto-fetch-indicator"); try { await fetchForecastForProfile(profileId); forecasts = await dbGetByIndex("forecasts", "profileId", profileId); } catch (fetchErr) { hide("loading"); hide("auto-fetch-indicator"); displayDetailedError(fetchErr); show("error-state"); return; } hide("auto-fetch-indicator"); } } if (forecasts.length === 0) { hide("loading"); show("no-forecast"); return; } const today = new Date().toISOString().slice(0, 10); const payload = await getComputePayload(profileId, today); if (!payload) { hide("loading"); show("no-data"); return; } const resp = await fetch("/api/compute/dashboard", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || "Compute failed"); } const data = await resp.json(); // Load unit preference const savedUnit = await getSetting("capacityUnit"); if (savedUnit === "kw" || savedUnit === "btuh") _capacityUnit = savedUnit; hide("loading"); show("data-display"); await renderDashboard(data); await initProfileSwitcher(profileId); initQuickSettings(); // LLM credentials (shared between summary and actions) const llmProvider = await getSetting("llmProvider"); const llmApiKey = await getSetting("llmApiKey"); const llmModel = await getSetting("llmModel"); const language = window.HG && window.HG.lang === "de" ? "German" : "English"; // LLM summary (async) try { const llmBody = { date: data.date, peakTempC: data.peakTempC, minNightTempC: data.minNightTempC, riskLevel: data.riskLevel, acHeadroomBTUH: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].headroomBtuh : 0, budgetStatus: data.roomBudgets && data.roomBudgets.length > 0 ? data.roomBudgets[0].status : "comfortable", language: language, }; if (llmProvider && llmApiKey) { llmBody.provider = llmProvider; llmBody.apiKey = llmApiKey; if (llmModel) llmBody.model = llmModel; } const llmResp = await fetch("/api/llm/summarize", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(llmBody), }); if (llmResp.ok) { const llmData = await llmResp.json(); if (llmData.summary) { renderLlmSummary(llmData.summary); } else { $("llm-section").classList.add("hidden"); } } } catch (e) { $("llm-section").classList.add("hidden"); } // AI actions (async) — skip when no heat risk const needsHeatActions = data.peakTempC >= 22 || data.riskLevel !== "low"; if (llmProvider && llmApiKey && needsHeatActions) { show("actions-loading"); try { const rooms = (payload.rooms || []).map(r => ({ name: r.name, orientation: r.orientation || "", shadingType: r.shadingType || "none", hasAC: (payload.acAssignments || []).some(a => a.roomId === r.id), })); const actionsBody = { date: data.date, indoorTempC: data.indoorTempC, peakTempC: data.peakTempC, minNightTempC: data.minNightTempC, poorNightCool: data.poorNightCool, riskLevel: data.riskLevel, riskWindows: data.riskWindows || [], timeline: (data.timeline || []).map(s => ({ hour: s.hour, tempC: s.tempC, humidityPct: s.humidityPct, budgetStatus: s.budgetStatus, coolMode: s.coolMode, })), roomBudgets: (data.roomBudgets || []).map(rb => ({ totalGainW: rb.totalGainW, })), rooms: rooms, provider: llmProvider, apiKey: llmApiKey, language: language, }; if (llmModel) actionsBody.model = llmModel; const actResp = await fetch("/api/llm/actions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(actionsBody), }); if (actResp.ok) { const actData = await actResp.json(); if (actData.actions && actData.actions.length > 0) { renderAIActions(actData.actions); _hourActionMap = buildHourActionMap(actData.actions); if (_currentTimeline) { renderTimelineAnnotations(_currentTimeline, _hourActionMap); renderTimelineLegend(_currentTimeline, _hourActionMap); } } else { hide("actions-loading"); } } else { hide("actions-loading"); } } catch (e) { hide("actions-loading"); } } } catch (err) { hide("loading"); displayDetailedError(err); show("error-state"); console.error("Dashboard error:", err); } }; // ========== Mini Markdown ========== function miniMarkdown(text) { return text .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/^- (.+)$/gm, "
${rendered}
`; }).join(""); const el = $("llm-summary"); el.innerHTML = `${esc(t().aiDisclaimer)}
`; // Fade in requestAnimationFrame(() => el.classList.remove("opacity-0")); } // ========== Main Render ========== async function renderDashboard(data) { $("profile-name").textContent = data.profileName + " \u2014 " + data.date; // Detect comfort day: cold weather, low risk const isComfort = data.peakTempC < 22 && data.riskLevel === "low"; const riskKey = isComfort ? "comfort" : data.riskLevel; // Risk card with gradient const rc = riskColors[riskKey] || riskColors.low; const rg = riskGradients[riskKey] || riskGradients.low; const dark = isDark(); const gradFrom = dark ? rg.darkFrom : rg.from; const gradTo = dark ? rg.darkTo : rg.to; const riskCard = $("risk-card"); riskCard.className = "rounded-xl p-4 text-center shadow-sm transition-all duration-200 hover:shadow-md"; riskCard.style.background = `linear-gradient(135deg, ${gradFrom}, ${gradTo})`; const shieldSvg = ''; const checkSvg = ''; $("risk-icon").innerHTML = `${isComfort ? checkSvg : shieldSvg}`; $("risk-level").className = `text-2xl font-bold capitalize ${rc.text}`; $("risk-level").textContent = isComfort ? (t().riskComfort || "Comfortable") : data.riskLevel; // Peak temp $("peak-temp").textContent = data.peakTempC.toFixed(1) + "\u00b0C"; // Min night temp $("min-night-temp").textContent = data.minNightTempC.toFixed(1) + "\u00b0C"; if (data.poorNightCool) { $("poor-night-cool").classList.remove("hidden"); } // Warnings if (data.warnings && data.warnings.length > 0) { show("warnings-section"); 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"); 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; 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 if (data.timeline && data.timeline.length > 0) { renderTimelineHeatmap(data.timeline, data.timezone, data.date); } // Room budgets if (data.roomBudgets && data.roomBudgets.length > 0) { show("budgets-section"); 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; 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 = fmtCap(rb.totalGainBtuh); el.querySelector('[data-slot="ac-capacity"]').textContent = fmtCap(rb.acCapacityBtuh); el.querySelector('[data-slot="headroom-value"]').textContent = fmtCap(rb.headroomBtuh); 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} ${fmtCap(Math.abs(rb.headroomBtuh))}`; 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)}%`; // Heating mode display const heatingSection = el.querySelector('[data-slot="heating-section"]'); if (rb.thermalMode === "heating" && heatingSection) { heatingSection.classList.remove("hidden"); const ts = t(); el.querySelector('[data-slot="heat-deficit"]').textContent = fmtCap(rb.heatDeficitBtuh || 0); el.querySelector('[data-slot="heating-capacity"]').textContent = fmtCap(rb.heatingCapBtuh || 0); el.querySelector('[data-slot="heating-headroom-value"]').textContent = fmtCap(rb.heatingHeadroom || 0); if ((rb.heatingHeadroom || 0) >= 0) { const hbad = el.querySelector('[data-slot="heating-headroom-bad"]'); if (hbad) hbad.remove(); } else { const hok = el.querySelector('[data-slot="heating-headroom-ok"]'); if (hok) hok.remove(); const hbad = el.querySelector('[data-slot="heating-headroom-bad"]'); if (hbad) { hbad.textContent = `${hbad.dataset.label} ${fmtCap(Math.abs(rb.heatingHeadroom || 0))}`; hbad.classList.remove("hidden"); } } } budgetContainer.appendChild(el); } } // Care checklist with persistence if (data.careChecklist && data.careChecklist.length > 0) { show("care-section"); 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"; const ranges = []; let start = hours[0], end = hours[0]; for (let i = 1; i < hours.length; i++) { if (hours[i] === end + 1) { end = hours[i]; } else { ranges.push(start === end ? String(start).padStart(2, '0') + ":00" : String(start).padStart(2, '0') + ":00\u2013" + String(end).padStart(2, '0') + ":00"); start = end = hours[i]; } } ranges.push(start === end ? String(start).padStart(2, '0') + ":00" : String(start).padStart(2, '0') + ":00\u2013" + String(end).padStart(2, '0') + ":00"); return ranges.join(", "); } // ========== AI Actions ========== function renderAIActions(actions) { const groups = {}; actions.forEach(a => { const cat = a.category || "other"; if (!groups[cat]) groups[cat] = []; groups[cat].push(a); }); const categoryOrder = ["shading", "ventilation", "internal_gains", "ac_strategy", "hydration", "care"]; const sortedCats = Object.keys(groups).sort((a, b) => { const ia = categoryOrder.indexOf(a); const ib = categoryOrder.indexOf(b); return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib); }); const list = $("actions-list"); list.replaceChildren(); 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"); const badge = $("actions-badge"); if (badge) { badge.textContent = t().aiActions || "AI"; badge.classList.remove("hidden"); } } // ========== Heatmap Timeline ========== const coolModeColors = { comfort: "#6ee7b7", ventilate: "#38bdf8", ac: "#4ade80", overloaded: "#f87171", sealed: "#a78bfa", heating: "#818cf8", heat_insufficient: "#c084fc", }; const categoryColors = { shading: "#f59e0b", ventilation: "#38bdf8", internal_gains: "#a78bfa", ac_strategy: "#4ade80", hydration: "#60a5fa", care: "#fb7185", }; const coolModeLabels = () => ({ comfort: t().coolComfort || "No cooling needed", ventilate: t().coolVentilate || "Open windows", ac: t().coolAC || "AC cooling", overloaded: t().coolOverloaded || "AC overloaded", sealed: t().coolSealed || "Keep sealed", heating: t().coolHeating || "Heating", heat_insufficient: t().coolHeatInsufficient || "Heating insufficient", }); function renderTimelineHeatmap(timeline, timezone, dashDate) { _currentTimeline = timeline; _currentTimezone = timezone || null; _currentDashDate = dashDate || null; const container = $("timeline-chart"); const tooltip = $("timeline-tooltip"); const labels = coolModeLabels(); // Determine current hour in profile timezone (only if dashboard date is today) let currentHour = -1; if (timezone && dashDate) { try { const now = new Date(); const todayInTz = now.toLocaleDateString("en-CA", { timeZone: timezone }); if (todayInTz === dashDate) { const hourStr = now.toLocaleString("en-US", { timeZone: timezone, hour12: false, hour: "2-digit" }); currentHour = parseInt(hourStr, 10); } } catch (_) { /* invalid timezone, skip */ } } // Hour labels (every 3h) + current hour marker const hourLabelsHtml = timeline.map(s => { const isCurrent = s.hour === currentHour; if (isCurrent) return `