// Dashboard page logic
(function() {
"use strict";
const $ = (id) => document.getElementById(id);
function show(id) { $(id).classList.remove("hidden"); }
function hide(id) { $(id).classList.add("hidden"); }
const riskColors = {
low: { bg: "bg-green-100 dark:bg-green-900", text: "text-green-700 dark:text-green-300", border: "border-green-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 budgetColors = {
comfortable: "bg-green-500",
marginal: "bg-yellow-500",
overloaded: "bg-red-500",
};
const budgetHexColors = {
comfortable: "#22c55e",
marginal: "#eab308",
overloaded: "#ef4444",
};
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";
return "#bfdbfe";
}
window.loadDashboard = async function() {
hide("no-data");
hide("no-forecast");
hide("error-state");
hide("data-display");
show("loading");
try {
const profileId = await getActiveProfileId();
if (!profileId) {
hide("loading");
show("no-data");
return;
}
const forecasts = await dbGetByIndex("forecasts", "profileId", profileId);
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();
hide("loading");
show("data-display");
renderDashboard(data);
// 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: window.HG && window.HG.lang === "de" ? "German" : "English",
};
// Include client-side LLM settings if available
const llmProvider = await getSetting("llmProvider");
const llmApiKey = await getSetting("llmApiKey");
const llmModel = await getSetting("llmModel");
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) {
$("llm-summary").innerHTML = `
${esc(llmData.summary)}
AI-generated summary. Not a substitute for professional advice.
`;
} else {
$("llm-section").classList.add("hidden");
}
}
} catch (e) {
$("llm-section").classList.add("hidden");
}
} catch (err) {
hide("loading");
show("error-state");
console.error("Dashboard error:", err);
}
};
function renderDashboard(data) {
$("profile-name").textContent = data.profileName + " \u2014 " + data.date;
// Risk card
const rc = riskColors[data.riskLevel] || riskColors.low;
$("risk-card").className = `rounded-xl p-4 text-center shadow-sm ${rc.bg}`;
$("risk-level").className = `text-2xl font-bold capitalize ${rc.text}`;
$("risk-level").textContent = 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");
$("warnings-section").innerHTML = 'Warnings
' +
data.warnings.map(w => `
${esc(w.headline)}
${esc(w.description)}
${w.instruction ? `
${esc(w.instruction)}
` : ''}
${esc(w.onset)} \u2014 ${esc(w.expires)}
`).join("");
}
// Risk windows
if (data.riskWindows && data.riskWindows.length > 0) {
show("risk-windows-section");
$("risk-windows").innerHTML = data.riskWindows.map(w => {
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("");
}
// Timeline SVG chart + budget strip
if (data.timeline && data.timeline.length > 0) {
renderTimelineChart(data.timeline);
renderBudgetStrip(data.timeline);
}
// Room budgets
if (data.roomBudgets && data.roomBudgets.length > 0) {
show("budgets-section");
$("room-budgets").innerHTML = data.roomBudgets.map(rb => {
const maxVal = Math.max(rb.totalGainBtuh, rb.acCapacityBtuh, 1);
const gainPct = Math.min((rb.totalGainBtuh / maxVal) * 100, 100);
const capPct = Math.min((rb.acCapacityBtuh / maxVal) * 100, 100);
return `
${esc(rb.roomName)}
Internal${rb.internalGainsW.toFixed(0)} W
Solar${rb.solarGainW.toFixed(0)} W
Ventilation${rb.ventGainW.toFixed(0)} W
Total${rb.totalGainBtuh.toFixed(0)} BTU/h
AC Capacity${rb.acCapacityBtuh.toFixed(0)} BTU/h
Headroom${rb.headroomBtuh.toFixed(0)} BTU/h
`;
}).join("");
}
// Care checklist
if (data.careChecklist && data.careChecklist.length > 0) {
show("care-section");
$("care-checklist").innerHTML = data.careChecklist.map(item => `
${esc(item)}
`).join("");
}
}
// ========== SVG Timeline Chart ==========
function renderTimelineChart(timeline) {
const container = $("timeline-chart");
const width = 720;
const height = 200;
const padLeft = 40;
const padRight = 10;
const padTop = 15;
const padBottom = 25;
const chartW = width - padLeft - padRight;
const chartH = height - padTop - padBottom;
const temps = timeline.map(s => s.tempC);
const minTemp = Math.floor(Math.min(...temps) / 5) * 5;
const maxTemp = Math.ceil(Math.max(...temps) / 5) * 5;
const tempRange = maxTemp - minTemp || 10;
function x(i) { return padLeft + (i / (timeline.length - 1)) * chartW; }
function y(t) { return padTop + (1 - (t - minTemp) / tempRange) * chartH; }
// Build area path (temperature curve filled to bottom)
const points = timeline.map((s, i) => `${x(i).toFixed(1)},${y(s.tempC).toFixed(1)}`);
const linePath = `M${points.join(" L")}`;
const areaPath = `${linePath} L${x(timeline.length - 1).toFixed(1)},${(padTop + chartH).toFixed(1)} L${padLeft},${(padTop + chartH).toFixed(1)} Z`;
// Build gradient stops based on temperature
const gradientStops = timeline.map((s, i) => {
const pct = ((i / (timeline.length - 1)) * 100).toFixed(1);
return ``;
}).join("");
const gradientStopsLine = timeline.map((s, i) => {
const pct = ((i / (timeline.length - 1)) * 100).toFixed(1);
return ``;
}).join("");
// Threshold lines
const thresholds = [30, 35, 40].filter(t => t > minTemp && t < maxTemp);
const thresholdLines = thresholds.map(t =>
`
${t}\u00b0`
).join("");
// Y-axis labels (min, max)
const yLabels = `
${maxTemp}\u00b0
${minTemp}\u00b0
`;
// X-axis labels (every 3 hours)
const xLabels = timeline
.filter((s, i) => s.hour % 3 === 0)
.map((s, i) => {
const idx = timeline.findIndex(t => t.hour === s.hour);
return `${String(s.hour).padStart(2, '0')}`;
}).join("");
// Data points (circles)
const circles = timeline.map((s, i) =>
``
).join("");
const svg = ``;
container.innerHTML = svg;
// Tooltip on hover/click
const tooltip = $("timeline-tooltip");
container.querySelectorAll(".timeline-dot").forEach(dot => {
const handler = (e) => {
const idx = parseInt(dot.dataset.idx);
const slot = timeline[idx];
const actions = slot.actions && slot.actions.length > 0
? slot.actions.map(a => `\u2022 ${esc(a.name)}`).join("
")
: "No actions";
tooltip.innerHTML = `
${slot.hourStr}
${slot.tempC.toFixed(1)}\u00b0C ยท ${(slot.humidityPct || 0).toFixed(0)}% RH
${slot.budgetStatus}
${actions}
`;
const rect = dot.getBoundingClientRect();
tooltip.style.left = (rect.left + window.scrollX - 60) + "px";
tooltip.style.top = (rect.top + window.scrollY - tooltip.offsetHeight - 8) + "px";
tooltip.classList.remove("hidden");
};
dot.addEventListener("mouseenter", handler);
dot.addEventListener("click", handler);
});
container.addEventListener("mouseleave", () => tooltip.classList.add("hidden"));
}
// ========== Budget Status Strip ==========
function renderBudgetStrip(timeline) {
const strip = $("timeline-strip");
strip.innerHTML = timeline.map(slot => {
const color = budgetHexColors[slot.budgetStatus] || "#d1d5db";
return ``;
}).join("");
}
function esc(s) {
if (!s) return "";
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
// Init
loadDashboard();
})();