// 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, "
  • $1
  • "); } // ========== LLM Summary ========== function renderLlmSummary(summary) { const paragraphs = summary.split(/\n\n+/).filter(Boolean); const html = paragraphs.map(p => { const rendered = miniMarkdown(esc(p)); if (rendered.includes("${rendered}`; } return `

    ${rendered}

    `; }).join(""); const el = $("llm-summary"); el.innerHTML = `
    ${html}

    ${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 `
    \u25bc
    `; if (s.hour % 3 !== 0) return `
    `; return `
    ${String(s.hour).padStart(2, "0")}
    `; }).join(""); // Temp cells const tempCellsHtml = timeline.map((s, i) => { const color = tempColorHex(s.tempC); const textColor = (s.tempC >= 35 || s.tempC < 0) ? "white" : "#1f2937"; const isCurrent = s.hour === currentHour; const ringStyle = isCurrent ? "outline:2px solid white;outline-offset:-2px;box-shadow:0 0 0 3px rgba(249,115,22,0.6);" : ""; return `
    ` + `
    `; }).join(""); // Cooling mode cells const coolCellsHtml = timeline.map(s => { const mode = s.coolMode || "ac"; const color = coolModeColors[mode] || "#d1d5db"; return `
    `; }).join(""); container.innerHTML = `
    ${hourLabelsHtml}
    ${tempCellsHtml}
    ${coolCellsHtml}
    `; // Tooltip handlers container.querySelectorAll(".hm-temp-cell").forEach(cell => { const handler = () => { const idx = parseInt(cell.dataset.idx); const slot = timeline[idx]; const modeLabel = labels[slot.coolMode] || slot.coolMode || ""; const uviStr = slot.uvIndex ? ` \u00b7 UV ${slot.uvIndex.toFixed(1)}` : ""; let tooltipHtml = `
    ${slot.hourStr}
    ${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH${slot.pressureHpa ? ` \u00b7 ${slot.pressureHpa.toFixed(0)} hPa` : ""}${uviStr}
    ${slot.budgetStatus} \u00b7 ${esc(modeLabel)}
    `; const hourActions = _hourActionMap && _hourActionMap[slot.hour]; if (hourActions && hourActions.length > 0) { const catLabels = (t().category) || {}; const maxShow = 4; const shown = hourActions.slice(0, maxShow); const remaining = hourActions.length - maxShow; tooltipHtml += `
    `; shown.forEach(a => { const color = categoryColors[a.category] || "#9ca3af"; tooltipHtml += `
    ${esc(a.name)}
    `; }); if (remaining > 0) { tooltipHtml += `
    +${remaining} more
    `; } tooltipHtml += `
    `; } tooltip.innerHTML = tooltipHtml; const rect = cell.getBoundingClientRect(); const parentRect = tooltip.parentElement.getBoundingClientRect(); tooltip.style.left = (rect.left - parentRect.left + rect.width / 2 - 60) + "px"; tooltip.style.top = (rect.top - parentRect.top - tooltip.offsetHeight - 8) + "px"; tooltip.classList.remove("hidden"); }; cell.addEventListener("mouseenter", handler); cell.addEventListener("click", handler); }); container.addEventListener("mouseleave", () => tooltip.classList.add("hidden")); // Legend (initial render without AI data) renderTimelineLegend(timeline, _hourActionMap); } // ========== AI Timeline Annotations ========== function buildHourActionMap(actions) { const map = {}; (actions || []).forEach(a => { (a.hours || []).forEach(h => { if (!map[h]) map[h] = []; map[h].push(a); }); }); return Object.keys(map).length > 0 ? map : null; } function renderTimelineLegend(timeline, hourActionMap) { const legend = $("cooling-legend"); if (!legend) return; const labels = coolModeLabels(); const ts = t(); // Temperature scale const tempSteps = [ { label: "<0", color: "#6366f1" }, { label: "0", color: "#93c5fd" }, { label: "10", color: "#bfdbfe" }, { label: "20", color: "#bbf7d0" }, { label: "25", color: "#fde68a" }, { label: "30", color: "#facc15" }, { label: "35", color: "#f97316" }, { label: "40+", color: "#dc2626" }, ]; let html = `
    `; html += `${esc(ts.legendTemp || "Temperature")}`; html += tempSteps.map(s => `${s.label}` ).join(""); html += `
    `; // Cooling modes const usedModes = [...new Set((timeline || []).map(s => s.coolMode || "ac"))]; html += `
    `; html += `${esc(ts.legendCooling || "Cooling")}`; html += usedModes.map(mode => { const color = coolModeColors[mode] || "#d1d5db"; const lbl = labels[mode] || mode; return `${esc(lbl)}`; }).join(""); html += `
    `; // AI categories (only when hourActionMap has entries) if (hourActionMap) { const usedCats = new Set(); Object.values(hourActionMap).forEach(actions => { actions.forEach(a => { if (a.category) usedCats.add(a.category); }); }); if (usedCats.size > 0) { const catLabels = ts.category || {}; html += `
    `; html += `${esc(ts.legendAI || "AI Actions")}`; const categoryOrder = ["shading", "ventilation", "internal_gains", "ac_strategy", "hydration", "care"]; const sorted = [...usedCats].sort((a, b) => { const ia = categoryOrder.indexOf(a); const ib = categoryOrder.indexOf(b); return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib); }); html += sorted.map(cat => { const color = categoryColors[cat] || "#9ca3af"; const lbl = catLabels[cat] || cat; return `${esc(lbl)}`; }).join(""); html += `
    `; } } legend.innerHTML = html; } function renderTimelineAnnotations(timeline, hourActionMap) { if (!hourActionMap || !timeline) return; const container = $("timeline-chart"); if (!container) return; // Remove previous annotation row if any const prev = container.querySelector(".hm-ai-row"); if (prev) prev.remove(); const cellsHtml = timeline.map(s => { const actions = hourActionMap[s.hour]; if (!actions || actions.length === 0) { return `
    `; } const cats = [...new Set(actions.map(a => a.category).filter(Boolean))]; const dots = cats.map(cat => { const color = categoryColors[cat] || "#9ca3af"; return ``; }).join(""); return `
    ${dots}
    `; }).join(""); const row = document.createElement("div"); row.className = "hm-ai-row grid gap-px mt-px"; row.style.gridTemplateColumns = `repeat(${timeline.length},minmax(0,1fr))`; row.innerHTML = cellsHtml; container.appendChild(row); } // ========== Forecast Refresh ========== async function refreshForecast() { const btn = $("refresh-forecast-btn"); const icon = $("refresh-icon"); if (!btn) return; btn.disabled = true; icon.classList.add("animate-spin"); try { const profileId = await getActiveProfileId(); if (!profileId) return; await fetchForecastForProfile(profileId); // Reset LLM/AI state so loadDashboard triggers fresh calls _hourActionMap = null; _currentTimeline = null; _currentTimezone = null; _currentDashDate = null; _qsInitialized = false; await loadDashboard(); } catch (err) { console.error("Forecast refresh error:", err); } finally { btn.disabled = false; icon.classList.remove("animate-spin"); } } // Attach handler after DOM is ready const refreshBtn = $("refresh-forecast-btn"); if (refreshBtn) refreshBtn.addEventListener("click", refreshForecast); const staleRetryBtn = $("stale-retry-btn"); if (staleRetryBtn) staleRetryBtn.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() { if (_qsInitialized) return; _qsInitialized = true; const toggle = $("qs-toggle"); const body = $("qs-body"); const chevron = $("qs-chevron"); if (!toggle || !body) return; toggle.addEventListener("click", () => { body.classList.toggle("hidden"); chevron.style.transform = body.classList.contains("hidden") ? "" : "rotate(180deg)"; }); // Load current values from first room try { const profileId = await getActiveProfileId(); if (profileId) { const rooms = await dbGetByIndex("rooms", "profileId", profileId); if (rooms.length > 0) { const r = rooms[0]; if (r.indoorTempC) $("qs-indoor-temp").value = r.indoorTempC; if (r.indoorHumidityPct) $("qs-indoor-humidity").value = r.indoorHumidityPct; } } } 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 { const profileId = await getActiveProfileId(); if (!profileId) return; const rooms = await dbGetByIndex("rooms", "profileId", profileId); for (const room of rooms) { if (!isNaN(tempVal) && tempVal >= 15 && tempVal <= 35) room.indoorTempC = tempVal; if (!isNaN(humVal) && humVal >= 20 && humVal <= 95) { room.indoorHumidityPct = humVal; } else { delete room.indoorHumidityPct; } await dbPut("rooms", room); } _qsInitialized = false; _hourActionMap = null; _currentTimeline = null; _currentTimezone = null; _currentDashDate = null; await loadDashboard(); } catch (e) { console.error("Quick settings apply error:", e); } finally { btn.disabled = false; btn.textContent = origText; } }); } function esc(s) { if (!s) return ""; const div = document.createElement("div"); div.textContent = s; return div.innerHTML; } // Init loadDashboard(); })();