Add bettervent.me provider with lazy-cached device list (~6,700 Eurovent-certified heat pumps), search API endpoint, device search UI with auto-populate, BTU/kW unit switcher for European users, and extended AC fields (SEER, SCOP, COP, TOL, Tbiv, refrigerant). Closes #2.
928 lines
36 KiB
JavaScript
928 lines
36 KiB
JavaScript
// 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;
|
|
}
|
|
|
|
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();
|
|
|
|
// 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");
|
|
show("error-state");
|
|
console.error("Dashboard error:", err);
|
|
}
|
|
};
|
|
|
|
// ========== Mini Markdown ==========
|
|
function miniMarkdown(text) {
|
|
return text
|
|
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/^- (.+)$/gm, "<li class=\"ml-4 list-disc\">$1</li>");
|
|
}
|
|
|
|
// ========== 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("<li")) {
|
|
return `<ul class="text-sm space-y-1">${rendered}</ul>`;
|
|
}
|
|
return `<p class="text-sm">${rendered}</p>`;
|
|
}).join("");
|
|
|
|
const el = $("llm-summary");
|
|
el.innerHTML = `
|
|
<div class="space-y-2">${html}</div>
|
|
<p class="text-xs text-gray-400 mt-3">${esc(t().aiDisclaimer)}</p>
|
|
`;
|
|
// 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 = '<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>';
|
|
const checkSvg = '<svg class="w-5 h-5 mx-auto" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><path d="M22 4L12 14.01l-3-3"/></svg>';
|
|
$("risk-icon").innerHTML = `<span class="${rc.text}">${isComfort ? checkSvg : shieldSvg}</span>`;
|
|
$("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 `<div class="text-center text-xs font-bold text-orange-500">\u25bc</div>`;
|
|
if (s.hour % 3 !== 0) return `<div></div>`;
|
|
return `<div class="text-center text-xs text-gray-400">${String(s.hour).padStart(2, "0")}</div>`;
|
|
}).join("");
|
|
|
|
// 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 `<div class="hm-temp-cell flex items-center justify-center rounded-sm cursor-pointer" data-idx="${i}" style="background:${color};color:${textColor};height:48px;${ringStyle}">`
|
|
+ `<span class="hm-temp-label hidden sm:inline text-xs font-medium">${Math.round(s.tempC)}</span></div>`;
|
|
}).join("");
|
|
|
|
// Cooling mode cells
|
|
const coolCellsHtml = timeline.map(s => {
|
|
const mode = s.coolMode || "ac";
|
|
const color = coolModeColors[mode] || "#d1d5db";
|
|
return `<div class="rounded-sm" style="background:${color};height:14px"></div>`;
|
|
}).join("");
|
|
|
|
container.innerHTML = `
|
|
<div class="grid gap-px" style="grid-template-columns:repeat(${timeline.length},minmax(0,1fr))">
|
|
${hourLabelsHtml}
|
|
</div>
|
|
<div class="grid gap-px mt-1" style="grid-template-columns:repeat(${timeline.length},minmax(0,1fr))">
|
|
${tempCellsHtml}
|
|
</div>
|
|
<div class="grid gap-px mt-px" style="grid-template-columns:repeat(${timeline.length},minmax(0,1fr))">
|
|
${coolCellsHtml}
|
|
</div>
|
|
`;
|
|
|
|
// 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 || "";
|
|
let tooltipHtml = `
|
|
<div class="font-medium mb-1">${slot.hourStr}</div>
|
|
<div>${slot.tempC.toFixed(1)}\u00b0C \u00b7 ${(slot.humidityPct || 0).toFixed(0)}% RH${slot.pressureHpa ? ` \u00b7 ${slot.pressureHpa.toFixed(0)} hPa` : ""}</div>
|
|
<div class="capitalize">${slot.budgetStatus} \u00b7 ${esc(modeLabel)}</div>
|
|
`;
|
|
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 += `<div class="border-t border-gray-600 mt-1.5 pt-1.5">`;
|
|
shown.forEach(a => {
|
|
const color = categoryColors[a.category] || "#9ca3af";
|
|
tooltipHtml += `<div class="flex items-center gap-1.5"><span class="inline-block rounded-full flex-shrink-0" style="width:5px;height:5px;background:${color}"></span><span>${esc(a.name)}</span></div>`;
|
|
});
|
|
if (remaining > 0) {
|
|
tooltipHtml += `<div class="text-gray-400">+${remaining} more</div>`;
|
|
}
|
|
tooltipHtml += `</div>`;
|
|
}
|
|
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 = `<div class="flex flex-wrap items-center gap-x-3 gap-y-1">`;
|
|
html += `<span class="font-medium text-gray-500 dark:text-gray-300">${esc(ts.legendTemp || "Temperature")}</span>`;
|
|
html += tempSteps.map(s =>
|
|
`<span class="flex items-center gap-1"><span class="inline-block w-3 h-3 rounded-sm" style="background:${s.color}"></span>${s.label}</span>`
|
|
).join("");
|
|
html += `</div>`;
|
|
|
|
// Cooling modes
|
|
const usedModes = [...new Set((timeline || []).map(s => s.coolMode || "ac"))];
|
|
html += `<div class="flex flex-wrap items-center gap-x-3 gap-y-1">`;
|
|
html += `<span class="font-medium text-gray-500 dark:text-gray-300">${esc(ts.legendCooling || "Cooling")}</span>`;
|
|
html += usedModes.map(mode => {
|
|
const color = coolModeColors[mode] || "#d1d5db";
|
|
const lbl = labels[mode] || mode;
|
|
return `<span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full" style="background:${color}"></span>${esc(lbl)}</span>`;
|
|
}).join("");
|
|
html += `</div>`;
|
|
|
|
// 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 += `<div class="flex flex-wrap items-center gap-x-3 gap-y-1">`;
|
|
html += `<span class="font-medium text-gray-500 dark:text-gray-300">${esc(ts.legendAI || "AI Actions")}</span>`;
|
|
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 `<span class="flex items-center gap-1"><span class="inline-block w-2 h-2 rounded-full" style="background:${color}"></span>${esc(lbl)}</span>`;
|
|
}).join("");
|
|
html += `</div>`;
|
|
}
|
|
}
|
|
|
|
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 `<div style="height:18px"></div>`;
|
|
}
|
|
const cats = [...new Set(actions.map(a => a.category).filter(Boolean))];
|
|
const dots = cats.map(cat => {
|
|
const color = categoryColors[cat] || "#9ca3af";
|
|
return `<span class="inline-block rounded-full" style="width:5px;height:5px;background:${color}"></span>`;
|
|
}).join("");
|
|
return `<div class="flex items-center justify-center gap-px flex-wrap" style="height:18px">${dots}</div>`;
|
|
}).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);
|
|
|
|
// ========== 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 = '<span class="inline-block animate-spin mr-1">\u21bb</span>' + esc(origText);
|
|
|
|
const tempVal = parseFloat($("qs-indoor-temp").value);
|
|
const humVal = parseFloat($("qs-indoor-humidity").value);
|
|
try {
|
|
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();
|
|
})();
|